diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts index 8a2ed5e859..0cb914e884 100644 --- a/open-sse/services/codexQuotaFetcher.ts +++ b/open-sse/services/codexQuotaFetcher.ts @@ -49,6 +49,13 @@ export interface CodexDualWindowQuota extends QuotaInfo { limitReached: boolean; /** All known Codex quota windows, including Spark when the upstream exposes it. */ allWindows?: Record; + /** + * Banked reset credits available on the account (display-only, issue #5199). + * Eligibility-gated: absent for most accounts. Never throws when missing. + */ + bankedResetCredits?: number; + /** Which window is currently reported as blocking, when the upstream exposes it. */ + rateLimitReachedType?: string; } interface CacheEntry { @@ -295,6 +302,26 @@ function parseCodexWindow( return { percentUsed, resetAt: parseWindowReset(window) }; } +/** + * Codex "banked reset credits" — same eligibility-gated field parsed in + * codexUsageQuotas.ts (kept in sync manually; the two parsers read the same + * /wham/usage payload independently). DISPLAY ONLY, never throws. + */ +function parseBankedResetCredits(data: Record): number | undefined { + const resetCredits = toRecord(data["rate_limit_reset_credits"] ?? data["rateLimitResetCredits"]); + const availableCount = resetCredits["available_count"] ?? resetCredits["availableCount"]; + const count = toNumber(availableCount, NaN); + return Number.isFinite(count) ? count : undefined; +} + +function parseRateLimitReachedType(data: Record): string | undefined { + const reachedType = data["rate_limit_reached_type"] ?? data["rateLimitReachedType"]; + if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim(); + const reachedTypeObj = toRecord(reachedType); + const type = reachedTypeObj["type"]; + return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined; +} + function findSparkRateLimit(data: Record): Record | null { const additional = data["additional_rate_limits"] ?? data["additionalRateLimits"]; if (!Array.isArray(additional)) return null; @@ -403,6 +430,9 @@ function parseCodexUsageResponse( secondary: CODEX_WINDOW_WEEKLY, }); + const bankedResetCredits = parseBankedResetCredits(obj); + const rateLimitReachedType = parseRateLimitReachedType(obj); + return { used: Math.round(worstPercentUsed * 100), total: 100, @@ -419,6 +449,9 @@ function parseCodexUsageResponse( window5h, window7d, limitReached, + // Banked reset credits (display-only, eligibility-gated — issue #5199). + ...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}), + ...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}), }; } diff --git a/open-sse/services/codexUsageQuotas.ts b/open-sse/services/codexUsageQuotas.ts index 6696daca0b..4d0bc0d857 100644 --- a/open-sse/services/codexUsageQuotas.ts +++ b/open-sse/services/codexUsageQuotas.ts @@ -160,13 +160,43 @@ function findCodexReviewRateLimit(data: JsonRecord): JsonRecord { return {}; } +/** + * Codex "banked reset credits" — an eligibility-gated field some ChatGPT plans + * expose on the /wham/usage payload: a count of extra rate-limit resets the + * account has banked (available_count), plus an optional descriptor of which + * window is currently blocking (rate_limit_reached_type). DISPLAY ONLY — this + * reads the field defensively (many accounts will not have it) and never + * throws; redemption is an unofficial mutating endpoint and out of scope + * (issue #5199). + */ +function parseBankedResetCredits(data: JsonRecord): number | undefined { + const resetCredits = toRecord(getFieldValue(data, "rate_limit_reset_credits", "rateLimitResetCredits")); + const availableCount = getFieldValue(resetCredits, "available_count", "availableCount"); + const count = toNumber(availableCount, NaN); + return Number.isFinite(count) ? count : undefined; +} + +function parseRateLimitReachedType(data: JsonRecord): string | undefined { + const reachedType = getFieldValue(data, "rate_limit_reached_type", "rateLimitReachedType"); + if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim(); + const reachedTypeObj = toRecord(reachedType); + const type = getFieldValue(reachedTypeObj, "type"); + return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined; +} + export function buildCodexUsageQuotas(dataValue: unknown): { rateLimit: JsonRecord; quotas: Record; + /** Banked reset credits available on the account (undefined when absent/not eligible). */ + bankedResetCredits?: number; + /** Which window is currently reported as blocking, when the upstream exposes it. */ + rateLimitReachedType?: string; } { const data = toRecord(dataValue); const rateLimit = toRecord(getFieldValue(data, "rate_limit", "rateLimit")); const quotas: Record = {}; + const bankedResetCredits = parseBankedResetCredits(data); + const rateLimitReachedType = parseRateLimitReachedType(data); const primaryWindow = toRecord(getFieldValue(rateLimit, "primary_window", "primaryWindow")); if (Object.keys(primaryWindow).length > 0) quotas.session = buildPercentageQuota(primaryWindow); @@ -231,5 +261,10 @@ export function buildCodexUsageQuotas(dataValue: unknown): { ); } - return { rateLimit, quotas }; + return { + rateLimit, + quotas, + ...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}), + ...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}), + }; } diff --git a/open-sse/services/usage/codex.ts b/open-sse/services/usage/codex.ts index b1d0261f03..564cbdad9d 100644 --- a/open-sse/services/usage/codex.ts +++ b/open-sse/services/usage/codex.ts @@ -57,12 +57,17 @@ export async function getCodexUsage( const data = await response.json(); - const { rateLimit, quotas } = buildCodexUsageQuotas(data); + const { rateLimit, quotas, bankedResetCredits, rateLimitReachedType } = + buildCodexUsageQuotas(data); return { plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"), limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")), quotas, + // Banked reset credits (display-only, eligibility-gated — issue #5199). + // Absent for most accounts; never throws when the upstream omits it. + ...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}), + ...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}), }; } catch (error) { return { message: `Failed to fetch Codex usage: ${(error as Error).message}` }; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 5b567b0157..04346f1dc1 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -110,13 +110,30 @@ function parseAntigravity(data: any) { .filter(Boolean); } +/** + * Codex "banked reset credits" — an eligibility-gated field (issue #5199): + * a count of extra rate-limit resets the account has banked. DISPLAY ONLY + * (redemption is an unofficial mutating endpoint, out of scope). Most + * accounts won't have this field; only render it when the count is positive. + */ +function buildBankedResetCreditsQuota(count: number) { + return buildCreditsQuota("banked_reset_credits", count, 100, { currency: "" }); +} + function parseCodex(data: any) { - return quotaEntries(data).map(([quotaType, quota]) => + const quotas = quotaEntries(data).map(([quotaType, quota]) => normalizeQuotaEntry(quotaType, quota, { displayName: quota?.displayName, isPercentageOnly: true, }) ); + + const bankedResetCredits = Number(data?.bankedResetCredits); + if (Number.isFinite(bankedResetCredits) && bankedResetCredits > 0) { + quotas.push(buildBankedResetCreditsQuota(bankedResetCredits)); + } + + return quotas; } function parseClaude(data: any) { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index e19f8b973b..f99f0f9c85 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -33,6 +33,7 @@ const QUOTA_LABEL_MAP: Record = { "Monthly Tools": "Monthly Tools", tokens: "Tokens", time_limit: "Time Limit", + banked_reset_credits: "Banked Reset Credits", }; function toRecord(value: unknown): Record { diff --git a/tests/unit/codex-banked-reset-credits-5199.test.ts b/tests/unit/codex-banked-reset-credits-5199.test.ts new file mode 100644 index 0000000000..b831956bfd --- /dev/null +++ b/tests/unit/codex-banked-reset-credits-5199.test.ts @@ -0,0 +1,200 @@ +/** + * Regression test for Codex "banked reset credits" (issue #5199). + * + * DISPLAY ONLY: OmniRoute already calls the ChatGPT backend + * `/backend-api/wham/usage` endpoint for quota tracking. Some eligibility-gated + * accounts additionally expose `rate_limit_reset_credits.available_count` (a + * count of extra rate-limit resets banked on the account) and, optionally, + * `rate_limit_reached_type` (which window is currently blocking). This test + * verifies: + * 1. The field is parsed and surfaced additively when present, across both + * independent parsers that read this payload (codexUsageQuotas.ts used by + * the dashboard usage fetcher, and codexQuotaFetcher.ts used by the + * preflight/monitor fetcher). + * 2. Existing quota parsing is completely unaffected when the field is + * absent (fail-open — no throw, no regression to session/weekly/etc). + * + * Redemption of banked reset credits is an unofficial, mutating upstream + * endpoint and is explicitly OUT OF SCOPE — this only reads and surfaces data + * already present in the existing usage-fetch response. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildCodexUsageQuotas } from "../../open-sse/services/codexUsageQuotas.ts"; +import { getCodexUsage } from "../../open-sse/services/usage/codex.ts"; +import { + fetchCodexQuota, + invalidateCodexQuotaCache, + registerCodexConnection, + unregisterCodexConnection, +} from "../../open-sse/services/codexQuotaFetcher.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ─── codexUsageQuotas.ts (dashboard usage-fetch path) ────────────────────── + +test("buildCodexUsageQuotas surfaces bankedResetCredits when present", () => { + const { quotas, bankedResetCredits, rateLimitReachedType } = buildCodexUsageQuotas({ + rate_limit: { + primary_window: { used_percent: 10 }, + secondary_window: { used_percent: 20 }, + }, + rate_limit_reset_credits: { available_count: 3 }, + rate_limit_reached_type: { type: "secondary_window" }, + }); + + assert.equal(bankedResetCredits, 3); + assert.equal(rateLimitReachedType, "secondary_window"); + // Existing quotas remain intact. + assert.equal(quotas.session?.used, 10); + assert.equal(quotas.weekly?.used, 20); +}); + +test("buildCodexUsageQuotas tolerates camelCase field shape", () => { + const { bankedResetCredits, rateLimitReachedType } = buildCodexUsageQuotas({ + rateLimit: { primaryWindow: { usedPercent: 5 } }, + rateLimitResetCredits: { availableCount: 7 }, + rateLimitReachedType: "primary_window", + }); + + assert.equal(bankedResetCredits, 7); + assert.equal(rateLimitReachedType, "primary_window"); +}); + +test("buildCodexUsageQuotas leaves bankedResetCredits undefined when absent (fail-open)", () => { + const result = buildCodexUsageQuotas({ + rate_limit: { + primary_window: { used_percent: 10 }, + secondary_window: { used_percent: 20 }, + }, + }); + + assert.equal(result.bankedResetCredits, undefined); + assert.equal(result.rateLimitReachedType, undefined); + // Existing quota parsing is unaffected — no throw, no missing windows. + assert.equal(result.quotas.session?.used, 10); + assert.equal(result.quotas.weekly?.used, 20); +}); + +test("buildCodexUsageQuotas never throws on a garbage rate_limit_reset_credits shape", () => { + assert.doesNotThrow(() => { + const result = buildCodexUsageQuotas({ + rate_limit: { primary_window: { used_percent: 1 } }, + rate_limit_reset_credits: "not-an-object", + rate_limit_reached_type: 12345, + }); + assert.equal(result.bankedResetCredits, undefined); + assert.equal(result.rateLimitReachedType, undefined); + }); +}); + +// ─── usage/codex.ts (getCodexUsage — full dashboard fetch) ───────────────── + +test("getCodexUsage threads bankedResetCredits through additively", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 15 }, + secondary_window: { used_percent: 25 }, + }, + rate_limit_reset_credits: { available_count: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + + const usage = await getCodexUsage("token", { workspaceId: "ws-1" }); + + assert.equal((usage as any).plan, "plus"); + assert.equal((usage as any).bankedResetCredits, 2); + assert.equal((usage as any).quotas.session.used, 15); + assert.equal((usage as any).quotas.weekly.used, 25); +}); + +test("getCodexUsage omits bankedResetCredits and stays intact when absent", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 15 }, + secondary_window: { used_percent: 25 }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + + const usage = await getCodexUsage("token", { workspaceId: "ws-1" }); + + assert.equal("bankedResetCredits" in (usage as any), false); + assert.equal((usage as any).quotas.session.used, 15); + assert.equal((usage as any).quotas.weekly.used, 25); +}); + +// ─── codexQuotaFetcher.ts (preflight/monitor path) ───────────────────────── + +test("fetchCodexQuota surfaces bankedResetCredits from the dual-window parser", async () => { + const connectionId = `codex-banked-${Date.now()}`; + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 70, reset_after_seconds: 45 }, + secondary_window: { used_percent: 20, reset_after_seconds: 300 }, + }, + rate_limit_reset_credits: { available_count: 4 }, + rate_limit_reached_type: { type: "primary_window" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + + const quota = await fetchCodexQuota(connectionId, { + accessToken: "token", + providerSpecificData: { workspaceId: "ws" }, + }); + + assert.ok(quota); + assert.equal(quota?.bankedResetCredits, 4); + assert.equal(quota?.rateLimitReachedType, "primary_window"); + // Existing dual-window parsing stays intact. + assert.equal(quota?.window5h.percentUsed, 0.7); + assert.equal(quota?.window7d.percentUsed, 0.2); + + invalidateCodexQuotaCache(connectionId); + unregisterCodexConnection(connectionId); +}); + +test("fetchCodexQuota omits bankedResetCredits when the payload does not have it (fail-open)", async () => { + const connectionId = `codex-nobanked-${Date.now()}`; + + registerCodexConnection(connectionId, { accessToken: "token" }); + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 30 }, + secondary_window: { used_percent: 10 }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + + const quota = await fetchCodexQuota(connectionId); + + assert.ok(quota); + assert.equal(quota?.bankedResetCredits, undefined); + assert.equal(quota?.rateLimitReachedType, undefined); + assert.equal(quota?.window5h.percentUsed, 0.3); + assert.equal(quota?.window7d.percentUsed, 0.1); + + invalidateCodexQuotaCache(connectionId); + unregisterCodexConnection(connectionId); +});