diff --git a/README.md b/README.md index cce683f..f56bc9f 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,11 @@ 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 │ @@ -50,10 +37,6 @@ 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 @@ -136,9 +119,7 @@ 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. -- 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. +- The only network destination is `https://chatgpt.com/backend-api/wham/usage`. See [SECURITY.md](./SECURITY.md) for the full security policy. diff --git a/SECURITY.md b/SECURITY.md index 453d2a7..c056265 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,12 +24,6 @@ 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 @@ -42,10 +36,8 @@ protects it. - **Never sends telemetry** — no analytics, no usage reporting, no phone-home. - **Never makes unexpected network requests** — the only network - 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. + destination is `https://chatgpt.com/backend-api/wham/usage`, and only + when credentials are available. - **Never executes install-time code** — the package has no `postinstall`, `preinstall`, or other lifecycle scripts. @@ -64,8 +56,8 @@ The `src/redact.ts` module provides: ### Unsupported endpoint risk -The ChatGPT backend usage and reset-details endpoints are -**undocumented and unsupported** by OpenAI. They may change shape, move, +The `https://chatgpt.com/backend-api/wham/usage` endpoint is +**undocumented and unsupported** by OpenAI. It may change shape, move, or disappear without notice. The plugin: - Validates the response at runtime with a tolerant Zod schema. @@ -73,8 +65,6 @@ 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 9b27a9d..aa5616f 100644 --- a/src/quota/cached-provider.ts +++ b/src/quota/cached-provider.ts @@ -109,7 +109,6 @@ 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 deleted file mode 100644 index df44db6..0000000 --- a/src/quota/reset-credits.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 28104bf..b7efb6e 100644 --- a/src/quota/schemas.ts +++ b/src/quota/schemas.ts @@ -13,8 +13,7 @@ */ import { z } from "zod"; -import { parseResetCreditsSummary } from "./reset-credits"; -import type { CreditsInfo, ResetCreditsInfo, UsageWindow } from "./types"; +import type { CreditsInfo, UsageWindow } from "./types"; import { identifyWindow } from "./types"; /** @@ -97,7 +96,6 @@ const WhamResponseSchema = z credits: CreditsSchema.optional(), extra_usage: CreditsSchema.optional(), extraUsage: CreditsSchema.optional(), - rate_limit_reset_credits: z.unknown().optional(), }) .passthrough(); @@ -109,7 +107,6 @@ export interface WhamParseResult { readonly windows: UsageWindow[]; readonly planType: string | null; readonly credits: CreditsInfo | null; - readonly resetCredits: ResetCreditsInfo | null; } /** @@ -190,14 +187,13 @@ 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, resetCredits: null }; + return { ok: false, windows: [], planType: null, credits: null }; } const data = parsed.data; @@ -243,7 +239,6 @@ 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 0103378..9e44edb 100644 --- a/src/quota/types.ts +++ b/src/quota/types.ts @@ -40,20 +40,6 @@ 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. @@ -67,7 +53,6 @@ export interface QuotaSnapshot { readonly weekly: UsageWindow | null; readonly unknownWindows: UsageWindow[]; readonly credits: CreditsInfo | null; - readonly resetCredits: ResetCreditsInfo | null; readonly warningCode: string | null; } @@ -88,7 +73,6 @@ 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 e39c7bb..2d960a1 100644 --- a/src/quota/wham-provider.ts +++ b/src/quota/wham-provider.ts @@ -3,7 +3,6 @@ * 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) @@ -24,7 +23,6 @@ */ import type { Credentials } from "./auth-reader"; -import { parseResetCreditsDetails } from "./reset-credits"; import { parseWhamResponse } from "./schemas"; import type { Clock, @@ -32,13 +30,11 @@ 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. @@ -69,7 +65,6 @@ 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; @@ -85,7 +80,6 @@ function buildSnapshot( weekly, unknownWindows, credits, - resetCredits, warningCode: null, }; } @@ -188,31 +182,6 @@ export class WhamProvider implements QuotaProvider { // Build the snapshot. const fetchedAt = new Date(this.deps.clock.now()).toISOString(); - 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); - } + return buildSnapshot(parsed.windows, parsed.planType, parsed.credits, fetchedAt); } } diff --git a/src/report/detailed.ts b/src/report/detailed.ts index 4c1cd5d..452a4c9 100644 --- a/src/report/detailed.ts +++ b/src/report/detailed.ts @@ -20,7 +20,6 @@ */ import type { Report, ReportModel } from "./build"; -import { formatResetCredits } from "./reset-credits"; /** * Format a number with comma-separated thousands. @@ -103,11 +102,6 @@ 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 2c44461..bd7e602 100644 --- a/src/report/json.ts +++ b/src/report/json.ts @@ -9,7 +9,6 @@ * provider; the session usage contains only token counts. */ -import type { ResetCreditsInfo } from "../quota/types"; import type { Report } from "./build"; /** @@ -40,7 +39,6 @@ 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; @@ -76,7 +74,6 @@ 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 deleted file mode 100644 index 60f3b5f..0000000 --- a/src/report/reset-credits.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 6f355ed..d3a621b 100644 --- a/src/tui/sidebar.tsx +++ b/src/tui/sidebar.tsx @@ -4,7 +4,6 @@ * 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: @@ -21,7 +20,6 @@ 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"; @@ -95,17 +93,6 @@ 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 deleted file mode 100644 index 5bc15ec..0000000 --- a/test/smoke/sidebar-render.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// 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 deleted file mode 100644 index fcf7a44..0000000 --- a/test/smoke/sidebar.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 bc4e9cf..0980f14 100644 --- a/test/unit/cached-provider.test.ts +++ b/test/unit/cached-provider.test.ts @@ -31,7 +31,6 @@ function makeSnapshot( }, unknownWindows: [], credits: { hasCredits: true, unlimited: false, balance: "14.50" }, - resetCredits: null, warningCode: null, }; } @@ -67,32 +66,6 @@ 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 edd743d..ad38182 100644 --- a/test/unit/report.test.ts +++ b/test/unit/report.test.ts @@ -64,7 +64,6 @@ function okQuota(overrides: Partial = {}): QuotaSnapshot { }, unknownWindows: [], credits: { hasCredits: true, unlimited: false, balance: "14.50" }, - resetCredits: null, warningCode: null, ...overrides, }; @@ -431,18 +430,6 @@ 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 deleted file mode 100644 index b3b8e5e..0000000 --- a/test/unit/reset-credits.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 81c7811..7608c50 100644 --- a/test/unit/wham-provider.test.ts +++ b/test/unit/wham-provider.test.ts @@ -329,108 +329,6 @@ 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(); @@ -444,7 +342,6 @@ 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);