diff --git a/README.md b/README.md index f56bc9f..cce683f 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,24 @@ quota** and **per-session token usage** — without spending a model turn. quota bars (5h and weekly windows) and per-model token usage, updating in real time as tokens stream. + Available manual usage limit resets appear below the quota bars, with + the limits they restore and expiration dates in your local time zone. + Reset information refreshes with quota data (every 90 seconds by default). + ```text ┌─ Codex Meter ────────────────────────────────┐ │ 5h quota [████████░░░░░░░░░░░░] 37% │ │ Weekly quota [████████████░░░░░░░░] 62% │ │ │ + │ Usage limit resets │ + │ 2 available │ + │ │ + │ Full reset (Weekly + 5 hr) │ + │ Expires Oct 4, 8:37 AM │ + │ │ + │ Full reset (Weekly + 5 hr) │ + │ Expires Oct 5, 7:21 AM │ + │ │ │ openai/gpt-5.5 (5 msgs) │ │ Input 184,230 │ │ Output 8,491 │ @@ -37,6 +50,10 @@ quota** and **per-session token usage** — without spending a model turn. Quota data comes from the ChatGPT backend and may be unavailable (the plugin keeps working with token totals only). Token totals are always available. +If reset details are unavailable, the plugin still shows the known count. +An unavailable count is shown as unavailable, never as zero. Cached reset +data is labeled stale after a failed quota refresh. The sidebar only displays +resets; use the Codex usage page to redeem one. ## Install @@ -119,7 +136,9 @@ All settings are optional environment variables: `~/.local/share/opencode/auth.json`. - Never reads, stores, or logs the `refresh` token. - Never writes to `auth.json` or refreshes OAuth credentials. -- The only network destination is `https://chatgpt.com/backend-api/wham/usage`. +- Makes read-only requests to `https://chatgpt.com/backend-api/wham/usage` + and, when resets are available, `/backend-api/wham/rate-limit-reset-credits` + on the same host. Never redeems a reset. See [SECURITY.md](./SECURITY.md) for the full security policy. diff --git a/SECURITY.md b/SECURITY.md index c056265..453d2a7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,6 +24,12 @@ protects it. The response contains usage percentages and reset times, not credentials. +- **Manual reset details** — when usage reports available resets, the plugin + reads `https://chatgpt.com/backend-api/wham/rate-limit-reset-credits`. + It retains only the available count and display details (reset type, + sanitized title, expiration) for available, unexpired, plan-supported resets. + Reset IDs, profile fields, and redemption history are discarded. + ### What the plugin never does - **Never writes to `auth.json`** — the plugin has no auth-write @@ -36,8 +42,10 @@ protects it. - **Never sends telemetry** — no analytics, no usage reporting, no phone-home. - **Never makes unexpected network requests** — the only network - destination is `https://chatgpt.com/backend-api/wham/usage`, and only - when credentials are available. + destinations are `https://chatgpt.com/backend-api/wham/usage` and + `https://chatgpt.com/backend-api/wham/rate-limit-reset-credits`, and only + when credentials are available. Both use GET; the plugin never redeems + or purchases resets. - **Never executes install-time code** — the package has no `postinstall`, `preinstall`, or other lifecycle scripts. @@ -56,8 +64,8 @@ The `src/redact.ts` module provides: ### Unsupported endpoint risk -The `https://chatgpt.com/backend-api/wham/usage` endpoint is -**undocumented and unsupported** by OpenAI. It may change shape, move, +The ChatGPT backend usage and reset-details endpoints are +**undocumented and unsupported** by OpenAI. They may change shape, move, or disappear without notice. The plugin: - Validates the response at runtime with a tolerant Zod schema. @@ -65,6 +73,8 @@ or disappear without notice. The plugin: - Preserves unknown windows rather than discarding them. - Treats any failure (401/403/429/5xx/timeout/malformed) as non-fatal — session token reporting continues independently. +- Keeps quota and the known reset count if the optional reset-details + request fails, times out, or changes schema. - Does not cache `unauthenticated` for the full TTL (uses a shorter 30-second negative cache). diff --git a/src/quota/cached-provider.ts b/src/quota/cached-provider.ts index aa5616f..9b27a9d 100644 --- a/src/quota/cached-provider.ts +++ b/src/quota/cached-provider.ts @@ -109,6 +109,7 @@ export class CachedProvider implements QuotaProvider { weekly: null, unknownWindows: [], credits: null, + resetCredits: null, warningCode: "UNAVAILABLE", }; } diff --git a/src/quota/reset-credits.ts b/src/quota/reset-credits.ts new file mode 100644 index 0000000..df44db6 --- /dev/null +++ b/src/quota/reset-credits.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { redact } from "../redact"; +import type { ResetCredit, ResetCreditsInfo } from "./types"; + +const SummarySchema = z.object({ available_count: z.number().int().nonnegative() }); +const DetailsSchema = SummarySchema.extend({ credits: z.array(z.unknown()) }); +const CreditSchema = z.object({ + reset_type: z.string().min(1), + status: z.string(), + is_supported_by_plan: z.boolean().optional(), + title: z.string().nullable().catch(null), + expires_at: z.string().datetime({ offset: true }).nullable(), +}); + +/** Reset metadata is optional: schema drift must not invalidate normal quota data. */ +export function parseResetCreditsSummary(raw: unknown): ResetCreditsInfo | null { + const parsed = SummarySchema.safeParse(raw); + if (!parsed.success) return null; + const availableCount = parsed.data.available_count; + return { availableCount, credits: availableCount === 0 ? [] : null }; +} + +export function parseResetCreditsDetails(raw: unknown, nowMs: number): ResetCreditsInfo | null { + const parsed = DetailsSchema.safeParse(raw); + if (!parsed.success) return null; + + const credits: ResetCredit[] = []; + for (const item of parsed.data.credits) { + const credit = CreditSchema.safeParse(item); + if (!credit.success) continue; + const data = credit.data; + if (data.status !== "available" || data.is_supported_by_plan === false) continue; + if (data.expires_at !== null && Date.parse(data.expires_at) <= nowMs) continue; + credits.push({ + resetType: data.reset_type, + title: data.title === null ? null : redact(data.title).replace(/\s+/g, " ").trim() || null, + expiresAt: data.expires_at === null ? null : new Date(data.expires_at).toISOString(), + }); + } + + credits.sort( + (a, b) => + (a.expiresAt === null ? Number.POSITIVE_INFINITY : Date.parse(a.expiresAt)) - + (b.expiresAt === null ? Number.POSITIVE_INFINITY : Date.parse(b.expiresAt)), + ); + + return { + availableCount: parsed.data.available_count, + credits: parsed.data.available_count === 0 ? [] : credits, + }; +} diff --git a/src/quota/schemas.ts b/src/quota/schemas.ts index b7efb6e..28104bf 100644 --- a/src/quota/schemas.ts +++ b/src/quota/schemas.ts @@ -13,7 +13,8 @@ */ import { z } from "zod"; -import type { CreditsInfo, UsageWindow } from "./types"; +import { parseResetCreditsSummary } from "./reset-credits"; +import type { CreditsInfo, ResetCreditsInfo, UsageWindow } from "./types"; import { identifyWindow } from "./types"; /** @@ -96,6 +97,7 @@ const WhamResponseSchema = z credits: CreditsSchema.optional(), extra_usage: CreditsSchema.optional(), extraUsage: CreditsSchema.optional(), + rate_limit_reset_credits: z.unknown().optional(), }) .passthrough(); @@ -107,6 +109,7 @@ export interface WhamParseResult { readonly windows: UsageWindow[]; readonly planType: string | null; readonly credits: CreditsInfo | null; + readonly resetCredits: ResetCreditsInfo | null; } /** @@ -187,13 +190,14 @@ export function parseWhamResponse(raw: unknown): WhamParseResult { windows: arrayResult, planType: null, credits: null, + resetCredits: null, }; } // Otherwise parse as an object. const parsed = WhamResponseSchema.safeParse(raw); if (!parsed.success) { - return { ok: false, windows: [], planType: null, credits: null }; + return { ok: false, windows: [], planType: null, credits: null, resetCredits: null }; } const data = parsed.data; @@ -239,6 +243,7 @@ export function parseWhamResponse(raw: unknown): WhamParseResult { windows, planType: normalizedPlanType, credits, + resetCredits: parseResetCreditsSummary(data.rate_limit_reset_credits), }; } diff --git a/src/quota/types.ts b/src/quota/types.ts index 9e44edb..0103378 100644 --- a/src/quota/types.ts +++ b/src/quota/types.ts @@ -40,6 +40,20 @@ export interface CreditsInfo { readonly balance: string | null; } +/** Display-only details for an available manual usage reset. */ +export interface ResetCredit { + readonly resetType: string; + readonly title: string | null; + readonly expiresAt: string | null; +} + +export interface ResetCreditsInfo { + /** Authoritative server count; the detail list may be incomplete. */ + readonly availableCount: number; + /** null means details could not be fetched or validated. */ + readonly credits: ResetCredit[] | null; +} + /** * A complete quota snapshot. This is the normalized representation * that all report formatters consume. @@ -53,6 +67,7 @@ export interface QuotaSnapshot { readonly weekly: UsageWindow | null; readonly unknownWindows: UsageWindow[]; readonly credits: CreditsInfo | null; + readonly resetCredits: ResetCreditsInfo | null; readonly warningCode: string | null; } @@ -73,6 +88,7 @@ export function noQuotaSnapshot( weekly: null, unknownWindows: [], credits: null, + resetCredits: null, warningCode, }; } diff --git a/src/quota/wham-provider.ts b/src/quota/wham-provider.ts index 2d960a1..e39c7bb 100644 --- a/src/quota/wham-provider.ts +++ b/src/quota/wham-provider.ts @@ -3,6 +3,7 @@ * ChatGPT backend endpoint. * * URL: GET https://chatgpt.com/backend-api/wham/usage + * Reset details: GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits * Headers: * Authorization: Bearer * ChatGPT-Account-Id: (only if accountId is present) @@ -23,6 +24,7 @@ */ import type { Credentials } from "./auth-reader"; +import { parseResetCreditsDetails } from "./reset-credits"; import { parseWhamResponse } from "./schemas"; import type { Clock, @@ -30,11 +32,13 @@ import type { HttpTransport, QuotaProvider, QuotaSnapshot, + ResetCreditsInfo, UsageWindow, } from "./types"; import { WarningCode, noQuotaSnapshot } from "./types"; const WHAM_URL = "https://chatgpt.com/backend-api/wham/usage"; +const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; /** * Configuration for the wham provider. @@ -65,6 +69,7 @@ function buildSnapshot( windows: UsageWindow[], planType: string | null, credits: QuotaSnapshot["credits"], + resetCredits: ResetCreditsInfo | null, fetchedAt: string, ): QuotaSnapshot { const fiveHour = windows.find((w) => w.kind === "five-hour") ?? null; @@ -80,6 +85,7 @@ function buildSnapshot( weekly, unknownWindows, credits, + resetCredits, warningCode: null, }; } @@ -182,6 +188,31 @@ export class WhamProvider implements QuotaProvider { // Build the snapshot. const fetchedAt = new Date(this.deps.clock.now()).toISOString(); - return buildSnapshot(parsed.windows, parsed.planType, parsed.credits, fetchedAt); + let resetCredits = parsed.resetCredits; + if (resetCredits !== null && resetCredits.availableCount > 0) { + // A failed detail lookup must not discard quota or the known reset count. + resetCredits = (await this.fetchResetCredits(headers)) ?? resetCredits; + } + return buildSnapshot(parsed.windows, parsed.planType, parsed.credits, resetCredits, fetchedAt); + } + + private async fetchResetCredits( + headers: Record, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.deps.config.timeoutMs); + try { + const response = await this.deps.transport.fetch(RESET_CREDITS_URL, { + method: "GET", + headers, + signal: controller.signal, + }); + if (!response.ok) return null; + return parseResetCreditsDetails(await response.json(), this.deps.clock.now()); + } catch { + return null; + } finally { + clearTimeout(timeoutId); + } } } diff --git a/src/report/detailed.ts b/src/report/detailed.ts index 452a4c9..4c1cd5d 100644 --- a/src/report/detailed.ts +++ b/src/report/detailed.ts @@ -20,6 +20,7 @@ */ import type { Report, ReportModel } from "./build"; +import { formatResetCredits } from "./reset-credits"; /** * Format a number with comma-separated thousands. @@ -102,6 +103,11 @@ function formatQuotaSection(report: Report): string { lines.push(` Unknown (${w.windowSeconds}s): ${pct}% used · resets in ${reset}`); } + lines.push("", "Usage limit resets"); + for (const line of formatResetCredits(q.resetCredits, q.status === "stale")) { + lines.push(line ? ` ${line}` : ""); + } + return `${lines.join("\n")}\n`; } diff --git a/src/report/json.ts b/src/report/json.ts index bd7e602..2c44461 100644 --- a/src/report/json.ts +++ b/src/report/json.ts @@ -9,6 +9,7 @@ * provider; the session usage contains only token counts. */ +import type { ResetCreditsInfo } from "../quota/types"; import type { Report } from "./build"; /** @@ -39,6 +40,7 @@ export interface JsonReport { readonly weekly: object | null; readonly unknownWindows: object[]; readonly credits: object | null; + readonly resetCredits: ResetCreditsInfo | null; readonly warningCode: string | null; } | null; readonly isWarning: boolean; @@ -74,6 +76,7 @@ export function toJsonReport(report: Report): JsonReport { weekly: report.quota.weekly, unknownWindows: report.quota.unknownWindows, credits: report.quota.credits, + resetCredits: report.quota.resetCredits, warningCode: report.quota.warningCode, } : null, diff --git a/src/report/reset-credits.ts b/src/report/reset-credits.ts new file mode 100644 index 0000000..60f3b5f --- /dev/null +++ b/src/report/reset-credits.ts @@ -0,0 +1,37 @@ +import type { ResetCredit, ResetCreditsInfo } from "../quota/types"; + +function resetTitle(credit: ResetCredit): string { + if (credit.resetType === "codex_rate_limits") return "Full reset (Weekly + 5 hr)"; + return credit.title ?? "Usage limit reset"; +} + +/** Shared sidebar/tool output. Dates use the user's local time zone. */ +export function formatResetCredits(info: ResetCreditsInfo | null, stale = false): string[] { + if (info === null) return ["Unavailable"]; + + const lines = [ + `${info.availableCount === 0 ? "No resets available" : `${info.availableCount} available`}${stale ? " (stale)" : ""}`, + ]; + if (info.availableCount === 0) return lines; + if (info.credits === null || info.credits.length === 0) { + lines.push("Details unavailable"); + return lines; + } + + for (const credit of info.credits) { + const expiry = + credit.expiresAt === null + ? "No expiration" + : `Expires ${new Date(credit.expiresAt).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}`; + lines.push("", resetTitle(credit), expiry); + } + if (info.availableCount > info.credits.length) { + lines.push("", "More reset details unavailable"); + } + return lines; +} diff --git a/src/tui/sidebar.tsx b/src/tui/sidebar.tsx index d3a621b..6f355ed 100644 --- a/src/tui/sidebar.tsx +++ b/src/tui/sidebar.tsx @@ -4,6 +4,7 @@ * Composes: * - Title: "Codex Meter" * - Quota section: 5h and weekly bars + reset info + * - Available manual usage resets with expiry dates * - Token section: per-model table + total * * Handles degraded states: @@ -20,6 +21,7 @@ import { Show, createMemo } from "solid-js"; import type { Report } from "../report/build"; import { formatResetDuration } from "../report/detailed"; +import { formatResetCredits } from "../report/reset-credits"; import { QuotaBar } from "./quota-bar"; import type { ThemeColors } from "./theme"; import { TokenTable } from "./token-table"; @@ -93,6 +95,17 @@ export function SidebarContent(props: SidebarContentProps) { >{`Quota: ${quota()?.status}`} + + + Usage limit resets + + {formatResetCredits(quota()?.resetCredits ?? null, quota()?.status === "stale").join( + "\n", + )} + + + + Tokens (this session) diff --git a/test/smoke/sidebar-render.tsx b/test/smoke/sidebar-render.tsx new file mode 100644 index 0000000..5bc15ec --- /dev/null +++ b/test/smoke/sidebar-render.tsx @@ -0,0 +1,69 @@ +// Run with Bun and the Solid preload; invoked by sidebar.test.ts. +import assert from "node:assert/strict"; +import { testRender } from "@opentui/solid"; +import { createSignal } from "solid-js"; +import { noQuotaSnapshot } from "../../src/quota/types"; +import { buildReport } from "../../src/report/build"; +import { SidebarContent } from "../../src/tui/sidebar"; +import type { ThemeColors } from "../../src/tui/theme"; + +const colors = { + text: "#eeeeee", + textMuted: "#888888", + border: "#444444", + quotaColor: () => "#00ff00", +} as unknown as ThemeColors; +const report = buildReport( + "test-session", + new Map(), + { + ...noQuotaSnapshot("ok", ""), + resetCredits: { + availableCount: 2, + credits: [ + { resetType: "codex_rate_limits", title: "Full reset", expiresAt: "2026-10-04T03:00:00Z" }, + { resetType: "codex_rate_limits", title: "Full reset", expiresAt: "2026-10-05T03:00:00Z" }, + ], + }, + }, + { generatedAt: "2026-09-09T00:00:00Z", warningThreshold: 80 }, +); +const [current, setCurrent] = createSignal(report); +const quota = report.quota; +assert.ok(quota); +const setup = await testRender( + () => , + { width: 34, height: 30 }, +); + +try { + await setup.renderOnce(); + const initial = setup.captureCharFrame(); + assert.match(initial, /Usage limit resets/); + assert.match(initial, /2 available/); + assert.match(initial, /Full reset \(Weekly \+ 5 hr\)/); + assert.match(initial, /Expires Oct 4/); + assert.match(initial, /Expires Oct 5/); + assert.match(initial, /Tokens \(this session\)/); + console.log(initial); + + setCurrent({ ...report, quota: { ...quota, resetCredits: { availableCount: 0, credits: [] } } }); + await setup.renderOnce(); + const empty = setup.captureCharFrame(); + assert.match(empty, /No resets available/); + assert.doesNotMatch(empty, /2 available|Expires Oct/); + + setCurrent({ + ...report, + quota: { ...quota, resetCredits: { availableCount: 2, credits: null } }, + }); + await setup.renderOnce(); + assert.match(setup.captureCharFrame(), /Details unavailable/); + + setCurrent({ ...report, quota: { ...quota, status: "stale" } }); + await setup.renderOnce(); + assert.match(setup.captureCharFrame(), /2 available \(stale\)/); + console.log("Sidebar rendering and reactive updates passed."); +} finally { + setup.renderer.destroy(); +} diff --git a/test/smoke/sidebar.test.ts b/test/smoke/sidebar.test.ts new file mode 100644 index 0000000..fcf7a44 --- /dev/null +++ b/test/smoke/sidebar.test.ts @@ -0,0 +1,17 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { expect, it } from "vitest"; + +it("renders reset details in a narrow sidebar and updates after quota refresh", () => { + const output = execFileSync( + "bun", + ["--preload", "@opentui/solid/preload", "test/smoke/sidebar-render.tsx"], + { + cwd: resolve(import.meta.dirname, "../.."), + env: { ...process.env, TZ: "UTC" }, + encoding: "utf8", + timeout: 10_000, + }, + ); + expect(output).toContain("Sidebar rendering and reactive updates passed."); +}); diff --git a/test/unit/cached-provider.test.ts b/test/unit/cached-provider.test.ts index 0980f14..bc4e9cf 100644 --- a/test/unit/cached-provider.test.ts +++ b/test/unit/cached-provider.test.ts @@ -31,6 +31,7 @@ function makeSnapshot( }, unknownWindows: [], credits: { hasCredits: true, unlimited: false, balance: "14.50" }, + resetCredits: null, warningCode: null, }; } @@ -66,6 +67,32 @@ function makeConfig(overrides: Partial = {}): CachedProvid // ── Tests ───────────────────────────────────────────────────────────── describe("CachedProvider", () => { + it("caches reset details, preserves them as stale on failure, and updates after redemption", async () => { + let time = 1000; + const resetCredits = { + availableCount: 2, + credits: [{ resetType: "codex_rate_limits", title: "Full reset", expiresAt: null }], + }; + const inner = makeInner([ + { ...makeSnapshot("ok"), resetCredits }, + makeSnapshot("unavailable"), + { ...makeSnapshot("ok"), resetCredits: { availableCount: 0, credits: [] } }, + ]); + const provider = new CachedProvider(inner, { + clock: { now: () => time }, + config: makeConfig({ ttlMs: 100 }), + }); + expect((await provider.fetch()).resetCredits).toEqual(resetCredits); + expect((await provider.fetch()).resetCredits).toEqual(resetCredits); + expect(inner.calls).toBe(1); + time += 200; + const stale = await provider.fetch(); + expect(stale.status).toBe("stale"); + expect(stale.resetCredits).toEqual(resetCredits); + time += 200; + expect((await provider.fetch()).resetCredits).toEqual({ availableCount: 0, credits: [] }); + }); + it("cache hit avoids a second network call", async () => { const inner = makeInner([makeSnapshot("ok")]); const provider = new CachedProvider(inner, { diff --git a/test/unit/report.test.ts b/test/unit/report.test.ts index ad38182..edd743d 100644 --- a/test/unit/report.test.ts +++ b/test/unit/report.test.ts @@ -64,6 +64,7 @@ function okQuota(overrides: Partial = {}): QuotaSnapshot { }, unknownWindows: [], credits: { hasCredits: true, unlimited: false, balance: "14.50" }, + resetCredits: null, warningCode: null, ...overrides, }; @@ -430,6 +431,18 @@ describe("formatDetailed", () => { // ── formatJson tests ────────────────────────────────────────────────── describe("formatJson", () => { + it("includes reset information in JSON and detailed reports", () => { + const resetCredits = { + availableCount: 2, + credits: [{ resetType: "codex_rate_limits", title: "Full reset", expiresAt: null }], + }; + const report = makeReport("s1", usageMap(), okQuota({ resetCredits })); + expect(toJsonReport(report).quota?.resetCredits).toEqual(resetCredits); + expect(formatDetailed(report)).toContain("Usage limit resets\n 2 available"); + expect(formatDetailed(report)).toContain("Full reset (Weekly + 5 hr)"); + expect(formatDetailed(report)).toContain("No expiration"); + }); + it("has schemaVersion: 1", () => { const report = makeReport("s1", usageMap(), null); const json = toJsonReport(report); diff --git a/test/unit/reset-credits.test.ts b/test/unit/reset-credits.test.ts new file mode 100644 index 0000000..b3b8e5e --- /dev/null +++ b/test/unit/reset-credits.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { parseResetCreditsDetails, parseResetCreditsSummary } from "../../src/quota/reset-credits"; +import { parseWhamResponse } from "../../src/quota/schemas"; +import { formatResetCredits } from "../../src/report/reset-credits"; + +const NOW = Date.parse("2026-09-09T00:00:00Z"); +// Synthetic values with the field shape verified against the live endpoint. +const credit = { + id: "private-reset-id", + reset_type: "codex_rate_limits", + is_supported_by_plan: true, + status: "available", + granted_at: "2026-09-04T03:00:00.123456Z", + expires_at: "2026-10-04T03:00:00.123456Z", + title: "Full reset", + description: "A complimentary reset", + profile_user_id: "private-profile-id", +}; + +describe("reset credit parsing", () => { + it("distinguishes unavailable counts, zero resets, and count-only data", () => { + expect(parseResetCreditsSummary(undefined)).toBeNull(); + expect(parseResetCreditsSummary(null)).toBeNull(); + expect(parseResetCreditsSummary({ available_count: 0 })).toEqual({ + availableCount: 0, + credits: [], + }); + expect(parseResetCreditsSummary({ available_count: 2 })).toEqual({ + availableCount: 2, + credits: null, + }); + }); + + it.each([-1, 1.5, "2", null, Number.NaN, Number.POSITIVE_INFINITY])( + "ignores malformed reset count %s without losing quota", + (available_count) => { + const parsed = parseWhamResponse({ + windows: [{ window_seconds: 18000, used_percent: 20 }], + rate_limit_reset_credits: { available_count }, + }); + expect(parsed.ok).toBe(true); + expect(parsed.windows[0]?.usedPercent).toBe(20); + expect(parsed.resetCredits).toBeNull(); + }, + ); + + it("reads live-shaped data, sorts expiry dates, and retains only display fields", () => { + const result = parseResetCreditsDetails( + { + available_count: 4, + credits: [ + { ...credit, expires_at: null }, + { ...credit, expires_at: "2026-10-05T03:00:00Z" }, + credit, + ], + total_earned_count: 8, + }, + NOW, + ); + expect(result?.availableCount).toBe(4); + expect(result?.credits).toEqual([ + { + resetType: "codex_rate_limits", + title: "Full reset", + expiresAt: "2026-10-04T03:00:00.123Z", + }, + { + resetType: "codex_rate_limits", + title: "Full reset", + expiresAt: "2026-10-05T03:00:00.000Z", + }, + { resetType: "codex_rate_limits", title: "Full reset", expiresAt: null }, + ]); + expect(JSON.stringify(result)).not.toContain("private-"); + expect(JSON.stringify(result)).not.toContain("description"); + }); + + it("skips redeemed, expired, unsupported, and malformed details without inferring a count", () => { + const result = parseResetCreditsDetails( + { + available_count: 2, + credits: [ + credit, + { ...credit, status: "redeemed" }, + { ...credit, status: "expired" }, + { ...credit, expires_at: new Date(NOW).toISOString() }, + { ...credit, is_supported_by_plan: false }, + { ...credit, expires_at: "bad-date" }, + { ...credit, expires_at: undefined }, + null, + ], + }, + NOW, + ); + expect(result?.availableCount).toBe(2); + expect(result?.credits).toHaveLength(1); + }); + + it("tolerates missing titles and plan flags, sanitizes titles, and handles zero", () => { + const result = parseResetCreditsDetails( + { + available_count: 2, + credits: [ + { ...credit, title: undefined, is_supported_by_plan: undefined }, + { ...credit, title: " Reset\nBearer ey_fake_access " }, + ], + }, + NOW, + ); + expect(result?.credits?.[0]?.title).toBeNull(); + expect(result?.credits?.[1]?.title).not.toContain("ey_fake_access"); + expect(result?.credits?.[1]?.title).not.toContain("\n"); + expect(parseResetCreditsDetails({ available_count: 0, credits: [credit] }, NOW)).toEqual({ + availableCount: 0, + credits: [], + }); + }); + + it.each([null, [], {}, { available_count: 2 }, { available_count: 2, credits: {} }])( + "rejects malformed detail responses", + (raw) => expect(parseResetCreditsDetails(raw, NOW)).toBeNull(), + ); +}); + +describe("sidebar and report reset display", () => { + it("shows the count, full reset scope, and expiry in local time", () => { + const parsed = parseResetCreditsDetails({ available_count: 1, credits: [credit] }, NOW); + const lines = formatResetCredits(parsed); + expect(lines).toContain("1 available"); + expect(lines).toContain("Full reset (Weekly + 5 hr)"); + expect(lines).toContain( + `Expires ${new Date(credit.expires_at).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}`, + ); + }); + + it("keeps unavailable, zero, and count-only states distinct", () => { + expect(formatResetCredits(null)).toEqual(["Unavailable"]); + expect(formatResetCredits({ availableCount: 0, credits: [] })).toEqual(["No resets available"]); + expect(formatResetCredits({ availableCount: 2, credits: null })).toEqual([ + "2 available", + "Details unavailable", + ]); + expect(formatResetCredits({ availableCount: 2, credits: [] })).toEqual([ + "2 available", + "Details unavailable", + ]); + }); + + it("labels stale and partial lists, unknown reset types, and no expiration", () => { + const lines = formatResetCredits( + { + availableCount: 3, + credits: [ + { resetType: "future_type", title: "Special reset", expiresAt: null }, + { resetType: "future_type", title: null, expiresAt: null }, + ], + }, + true, + ); + expect(lines).toContain("3 available (stale)"); + expect(lines).toContain("Special reset"); + expect(lines).toContain("Usage limit reset"); + expect(lines).toContain("No expiration"); + expect(lines).toContain("More reset details unavailable"); + expect(lines).not.toContain("Full reset (Weekly + 5 hr)"); + }); +}); diff --git a/test/unit/wham-provider.test.ts b/test/unit/wham-provider.test.ts index 7608c50..81c7811 100644 --- a/test/unit/wham-provider.test.ts +++ b/test/unit/wham-provider.test.ts @@ -329,6 +329,108 @@ describe("identifyWindow", () => { // ── WhamProvider tests ─────────────────────────────────────────────── describe("WhamProvider", () => { + const usageWithResets = { ...normalResponse, rate_limit_reset_credits: { available_count: 2 } }; + const resetDetails = { + available_count: 2, + credits: [ + { + id: "private-credit-id", + reset_type: "codex_rate_limits", + status: "available", + title: "Full reset", + expires_at: "2026-10-04T03:00:00.123456Z", + }, + ], + }; + + it("fetches reset details using the existing auth and keeps the authoritative count", async () => { + const { provider, transport } = makeProvider( + makeTransport([{ body: usageWithResets }, { body: resetDetails }]), + ); + const snap = await provider.fetch(); + expect(snap.status).toBe("ok"); + expect(snap.resetCredits?.availableCount).toBe(2); + expect(snap.resetCredits?.credits).toHaveLength(1); + expect(snap.resetCredits?.credits?.[0]?.expiresAt).toBe("2026-10-04T03:00:00.123Z"); + expect(transport.calls).toHaveLength(2); + expect(transport.calls[1]?.url).toBe( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", + ); + expect(transport.calls[1]?.headers).toEqual(transport.calls[0]?.headers); + expect(JSON.stringify(snap)).not.toContain("private-credit-id"); + expect(JSON.stringify(snap)).not.toContain(FAKE_ACCESS); + expect(JSON.stringify(snap)).not.toContain(FAKE_ACCOUNT); + }); + + it("skips the detail request when no resets are available", async () => { + const { provider, transport } = makeProvider( + makeTransport([ + { body: { ...normalResponse, rate_limit_reset_credits: { available_count: 0 } } }, + ]), + ); + expect((await provider.fetch()).resetCredits).toEqual({ availableCount: 0, credits: [] }); + expect(transport.calls).toHaveLength(1); + }); + + it("uses the newer detail count if a reset was redeemed between requests", async () => { + const { provider } = makeProvider( + makeTransport([{ body: usageWithResets }, { body: { available_count: 0, credits: [] } }]), + ); + expect((await provider.fetch()).resetCredits).toEqual({ availableCount: 0, credits: [] }); + }); + + it.each([401, 403, 404, 429, 503])( + "keeps fresh quota and the reset count when details return HTTP %s", + async (status) => { + const { provider } = makeProvider(makeTransport([{ body: usageWithResets }, { status }])); + const snap = await provider.fetch(); + expect(snap.status).toBe("ok"); + expect(snap.fiveHour?.usedPercent).toBe(37.5); + expect(snap.resetCredits).toEqual({ availableCount: 2, credits: null }); + }, + ); + + it("keeps the summary on malformed detail schemas", async () => { + const { provider } = makeProvider( + makeTransport([{ body: usageWithResets }, { body: { credits: "changed" } }]), + ); + expect((await provider.fetch()).resetCredits).toEqual({ availableCount: 2, credits: null }); + }); + + it.each(["network", "json", "timeout", "body-timeout"])( + "keeps quota and count after a detail %s failure", + async (failure) => { + const usageTransport = makeTransport([{ body: usageWithResets }]); + const transport: HttpTransport = { + async fetch(url, options) { + if (url.endsWith("/usage")) return usageTransport.fetch(url, options); + expect(options.method).toBe("GET"); + if (failure === "network") throw new Error("offline"); + const onAbort = () => + new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }); + if (failure === "timeout") return onAbort(); + return { + ok: true, + status: 200, + json: async () => { + if (failure === "body-timeout") return onAbort(); + throw new Error("invalid JSON"); + }, + text: async () => "", + }; + }, + }; + const { provider } = makeProvider(transport, goodCreds(), makeClock(1750000000000), 10); + const snap = await provider.fetch(); + expect(snap.status).toBe("ok"); + expect(snap.resetCredits).toEqual({ availableCount: 2, credits: null }); + }, + ); + it("fetches and normalizes a normal response", async () => { const { provider, transport } = makeProvider(makeTransport([{ body: normalResponse }])); const snap = await provider.fetch(); @@ -342,6 +444,7 @@ describe("WhamProvider", () => { expect(snap.weekly?.usedPercent).toBe(62.3); expect(snap.credits?.balance).toBe("14.50"); expect(snap.warningCode).toBeNull(); + expect(snap.resetCredits).toBeNull(); // Verify request. expect(transport.calls).toHaveLength(1);