diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts index 232904c520..03997ea2a9 100644 --- a/src/codex/auth-api/reset-credit-service.ts +++ b/src/codex/auth-api/reset-credit-service.ts @@ -153,7 +153,9 @@ export function createResetCreditWhamClient(config: OcxConfig, accountId: string signal: AbortSignal.timeout(10_000), }); if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } - return safeResetCreditConsumeDto(await resp.json()); + const parsed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (!parsed.ok) throw new Error("invalid upstream reset-credit consume response"); + return safeResetCreditConsumeDto(parsed.value); }), }; } @@ -383,7 +385,14 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, if (identity) markManualResetCreditOperationAmbiguous(identity); return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } - const result = safeResetCreditConsumeDto(await resp.json()); + const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (!consumed.ok) { + // The spend may already have landed upstream and its outcome code is unreadable, + // so this id must never come back as a new operation. + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: "Invalid upstream reset-credit consume response" }, 502); + } + const result = safeResetCreditConsumeDto(consumed.value); if (identity) { // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` // normalizes anything unrecognized to "unknown", and settling that diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ab7511c3b0..fc8030621b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -140,6 +140,10 @@ A confirmed manual reset-credit consumption may immediately reconcile that accou eligible pre-existing ordinary reset-derived cooldown after a complete, non-exhausted usage observation started after the reset. Paused or reauthentication-required accounts and cooldowns held by another in-flight probe remain excluded; their cooldowns are retained. +Confirmation requires a readable answer. Every reset-credit read, including the consume +response on both the manual and background paths, goes through the shared bounded-body +reader, so an upstream answer past that bound is unconfirmed rather than buffered whole. +An unconfirmed manual consume leaves its operation ambiguous and reconciles nothing. Recovery owns the specific cooldown and authenticates main and added Pool accounts through their respective credential contracts. Main usage publication keeps the latest successfully published observation authoritative. Pool recovery diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index bd30621448..7f49b31cb6 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,3 +1,4 @@ +import { registerResetCreditConsumeValidationTests } from "../helpers/reset-credit-consume-validation"; import * as usageHistoryModule from "../../src/usage/log"; import { getAccountQuotaHistory } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; @@ -2871,16 +2872,7 @@ describe("codex-auth API", () => { }); }); - 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", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "../bad" }), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); - expect(resp!.status).toBe(400); - expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); - }); + registerResetCreditConsumeValidationTests(makeConfig, seedPoolAccount); test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { const config = makeConfig(); diff --git a/tests/helpers/reset-credit-consume-validation.ts b/tests/helpers/reset-credit-consume-validation.ts new file mode 100644 index 0000000000..3f06e08dc8 --- /dev/null +++ b/tests/helpers/reset-credit-consume-validation.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { BOUNDED_BODY_MAX_BYTES } from "../../src/lib/bounded-body"; +import type { OcxConfig } from "../../src/types"; + +export function registerResetCreditConsumeValidationTests( + makeConfig: () => OcxConfig, + seedPoolAccount: (config: OcxConfig, options: { id: string; email: string }) => unknown, +): void { + 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", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "../bad" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + }); + + test("reset-credit consume refuses an upstream body past the shared bound instead of buffering it", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-oversized", email: "oversized@example.test" }); + const originalFetch = globalThis.fetch; + let usageCalls = 0; + try { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + // A 200 with an unbounded body was read whole by resp.json() before anything + // looked at its size, unlike every other reset-credit read on this path. + const padding = "x".repeat(BOUNDED_BODY_MAX_BYTES * 2); + return new Response(`{"code":"reset","padding":"${padding}"}`, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("/backend-api/wham/usage")) { + usageCalls += 1; + return Response.json({ + rate_limit: { primary_window: { used_percent: 10, reset_at: 1782000000 } }, + rate_limit_reset_credits: { available_count: 2 }, + }); + } + return originalFetch(input, init); + }) 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: "pool-oversized" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(502); + expect(await resp!.json()).toEqual({ error: "Invalid upstream reset-credit consume response" }); + // The outcome is unconfirmed, so nothing downstream may treat the redeem as observed. + expect(usageCalls).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + +}