From 639b6195fafce455789ecc6c01ecfdd07672d2e3 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Tue, 23 Jun 2026 11:58:29 -0700 Subject: [PATCH 1/4] feat(usage): show expiry dates for banked Codex rate-limit resets (#3339) The /usage display (TUI, ACP text, and omp usage CLI) now shows when banked Codex rate-limit resets expire, so users can plan when to redeem them before the 30-day window lapses. Changes: - Added UsageResetCreditDetail interface (grantedAt, expiresAt, status) and optional credits field to UsageResetCredits in packages/ai - Updated both ArkType schema copies (usage.ts + wire-schemas.ts) - The OpenAI Codex usage provider now calls listCodexResetCredits to fetch individual credit details when availableCount > 0, filtering out redeemed credits. Errors are logged and swallowed (graceful degradation: count shows without expiry dates) - TUI (command-controller.ts): shows per-credit expiry as relative time + absolute date under each account line - ACP text (usage-report.ts): same per-credit expiry rendering - CLI (usage-cli.ts): shows the soonest expiry date in the account header - 2 new tests: credit detail fetching with expiry dates, and no extra API call when availableCount is 0 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/auth-broker/wire-schemas.ts | 7 ++ packages/ai/src/usage.ts | 26 +++++++ packages/ai/src/usage/openai-codex.ts | 23 +++++++ packages/ai/test/openai-codex-usage.test.ts | 67 +++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/cli/usage-cli.ts | 18 ++++- .../modes/controllers/command-controller.ts | 17 +++++ .../slash-commands/helpers/usage-report.ts | 16 +++++ 9 files changed, 181 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 34c31852e..a2bce0d95 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `credits` field (array of `UsageResetCreditDetail` with `grantedAt`, `expiresAt`, `status`) to `UsageResetCredits`, so display layers can show when banked rate-limit resets expire. The OpenAI Codex usage provider now calls `listCodexResetCredits` to populate individual credit details when `availableCount > 0` ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)). + ## [16.1.16] - 2026-06-23 ### Fixed diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts index c22c76174..a658f3ac0 100644 --- a/packages/ai/src/auth-broker/wire-schemas.ts +++ b/packages/ai/src/auth-broker/wire-schemas.ts @@ -183,8 +183,15 @@ const usageLimitSchema = type({ "notes?": "string[]", }); +const usageResetCreditDetailSchema = type({ + "grantedAt?": "string", + "expiresAt?": "string", + "status?": "string", +}); + const usageResetCreditsSchema = type({ availableCount: "number", + "credits?": usageResetCreditDetailSchema.array(), }); const arkUsageReportSchema = type({ diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 45771607e..f3f8863ea 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -63,6 +63,23 @@ export interface UsageLimit { notes?: string[]; } +/** + * Per-credit detail for a saved/banked rate-limit reset. + * + * Populated when the provider's listing endpoint returns individual credit + * metadata (e.g. OpenAI Codex `wham/rate-limit-reset-credits`). Callers that + * only need the count can ignore this; display layers use `expiresAt` to show + * when banked resets expire ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)). + */ +export interface UsageResetCreditDetail { + /** ISO timestamp when the credit was granted. */ + grantedAt?: string; + /** ISO timestamp when the credit expires and can no longer be redeemed. */ + expiresAt?: string; + /** Backend status, e.g. `available`, `redeemed`. */ + status?: string; +} + /** * Saved/banked rate-limit resets an account can redeem on demand. * @@ -73,6 +90,8 @@ export interface UsageLimit { export interface UsageResetCredits { /** Number of resets available to redeem right now. */ availableCount: number; + /** Individual credit details (expiry dates, etc.) when the provider exposes them. */ + credits?: UsageResetCreditDetail[]; } /** Aggregated usage report for a provider. */ @@ -195,8 +214,15 @@ export const usageLimitSchema = type({ "notes?": "string[]", }); +export const usageResetCreditDetailSchema = type({ + "grantedAt?": "string", + "expiresAt?": "string", + "status?": "string", +}); + export const usageResetCreditsSchema = type({ availableCount: "number", + "credits?": usageResetCreditDetailSchema.array(), }); export const usageReportSchema = type({ diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 1efc2fbc1..66fccbcab 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -12,6 +12,7 @@ import type { UsageWindow, } from "../usage"; import { isRecord } from "../utils"; +import { listCodexResetCredits } from "./openai-codex-reset"; import { toNumber } from "./shared"; const CODEX_USAGE_PATH = "wham/usage"; @@ -470,6 +471,28 @@ export const openaiCodexUsageProvider: UsageProvider = { } const resetCredits = parseResetCredits(payload); + if (resetCredits && resetCredits.availableCount > 0) { + try { + const list = await listCodexResetCredits({ + accessToken, + accountId, + baseUrl: params.baseUrl, + fetch: ctx.fetch, + signal: params.signal, + }); + if (list?.credits.length) { + resetCredits.credits = list.credits + .filter(c => (c.status ?? "available") === "available") + .map(c => ({ + grantedAt: c.grantedAt, + expiresAt: c.expiresAt, + status: c.status, + })); + } + } catch (error) { + ctx.logger?.warn("Codex reset credits detail fetch failed", { error: String(error) }); + } + } const report: UsageReport = { provider: "openai-codex", fetchedAt: nowMs, diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 4407d98fb..58d7b6e80 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -149,4 +149,71 @@ describe("openai-codex usage parser", () => { ); expect(report?.resetCredits).toBeUndefined(); }); + it("populates resetCredits.credits with expiry dates when available_count > 0", async () => { + const usagePayload = { ...makePayload(), rate_limit_reset_credits: { available_count: 2 } }; + const creditsPayload = { + available_count: 2, + credits: [ + { + id: "RateLimitResetCredit_1", + status: "available", + granted_at: "2025-01-15T00:00:00Z", + expires_at: "2025-02-14T00:00:00Z", + }, + { + id: "RateLimitResetCredit_2", + status: "available", + granted_at: "2025-01-20T00:00:00Z", + expires_at: "2025-02-19T00:00:00Z", + }, + { + id: "RateLimitResetCredit_3", + status: "redeemed", + granted_at: "2025-01-01T00:00:00Z", + expires_at: "2025-01-31T00:00:00Z", + }, + ], + }; + const fetchImpl: FetchImpl = (async (url: string | URL | Request) => { + const path = typeof url === "string" ? url : url.toString(); + const body = path.includes("rate-limit-reset-credits") ? creditsPayload : usagePayload; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as FetchImpl; + const report = await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fetchImpl }, + ); + expect(report?.resetCredits?.availableCount).toBe(2); + // Redeemed credits are filtered out; only available ones surface + expect(report?.resetCredits?.credits).toHaveLength(2); + expect(report?.resetCredits?.credits?.[0]?.expiresAt).toBe("2025-02-14T00:00:00Z"); + expect(report?.resetCredits?.credits?.[1]?.expiresAt).toBe("2025-02-19T00:00:00Z"); + }); + + it("does not call listCodexResetCredits when available_count is 0", async () => { + const usagePayload = { ...makePayload(), rate_limit_reset_credits: { available_count: 0 } }; + let extraFetchCalls = 0; + const fetchImpl: FetchImpl = (async (url: string | URL | Request) => { + const path = typeof url === "string" ? url : url.toString(); + if (path.includes("rate-limit-reset-credits")) extraFetchCalls++; + return new Response(JSON.stringify(usagePayload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as FetchImpl; + await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fetchImpl }, + ); + expect(extraFetchCalls).toBe(0); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93f794f7f..3e63d4fe2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- The `/usage` display (TUI, ACP text, and `omp usage` CLI) now shows expiry dates for banked Codex rate-limit resets, so users can plan when to redeem them before they expire ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)). + ### Fixed - Fixed all extension loading silently failing on the cross-compiled `omp-darwin-arm64` release binary (downloaded directly or via a Homebrew tap wrapper) because `__computeBunfsPackageRoot` mis-handled `import.meta.dir = "//root/omp-darwin-arm64"`. Bun 1.3.14 reports `/` for the compiled entry's `import.meta.dir`, but the pre-fix function joined `metaDir + "packages"` and produced `/root/omp-darwin-arm64/packages` — the binary basename was baked into every bunfs path, so the TypeBox/legacy-pi shims and every `@oh-my-pi/pi-*` package-root override failed `existsSync` validation and `resolveCanonicalPiSpecifier` fell through to a bunfs `Bun.resolveSync` that also could not find the module. The function now detects the bunfs-root + binary-basename shape (`path.basename(path.dirname(metaDir)) === "root"`) and strips the trailing binary segment by slicing the original `metaDir`; the production bunfs shim join path also preserves Bun's bunfs-native `//root` / `B:\~BUN\root` prefix that `path.join` would otherwise collapse. ([#3329](https://github.com/can1357/oh-my-pi/issues/3329)) diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index e5b94250b..2f6edc087 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -326,7 +326,23 @@ function formatAccountHeader( const planType = report.metadata?.planType; if (typeof planType === "string" && planType) header += chalk.dim(` · plan: ${planType}`); const savedResets = report.resetCredits?.availableCount ?? 0; - if (savedResets > 0) header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`); + if (savedResets > 0) { + header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`); + const credits = report.resetCredits?.credits; + if (credits) { + const upcoming = credits + .filter(c => c.expiresAt) + .map(c => ({ date: c.expiresAt!, ms: Date.parse(c.expiresAt!) })) + .filter(c => !Number.isNaN(c.ms)) + .sort((a, b) => a.ms - b.ms) + .find(c => c.ms > nowMs); + if (upcoming) { + header += chalk.dim( + ` · soonest expires in ${formatDuration(upcoming.ms - nowMs)} (${upcoming.date.slice(0, 10)})`, + ); + } + } + } if (report.fetchedAt && nowMs - report.fetchedAt > 90_000) { header += chalk.dim(` · fetched ${formatDuration(nowMs - report.fetchedAt)} ago`); } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index e85ba8262..abe83be5a 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1598,6 +1598,23 @@ function renderUsageReports( resetAccountLines.push( ` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`, ); + const credits = report.resetCredits?.credits; + if (credits) { + for (const credit of credits) { + if (credit.expiresAt) { + const expiryMs = Date.parse(credit.expiresAt); + if (!Number.isNaN(expiryMs)) { + const remaining = expiryMs - nowMs; + const expiryDate = credit.expiresAt.slice(0, 10); + if (remaining > 0) { + resetAccountLines.push(` expires in ${formatDuration(remaining)} (${expiryDate})`); + } else { + resetAccountLines.push(` expired (${expiryDate})`); + } + } + } + } + } } if (resetAccountLines.length > 0) { lines.push( diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index c3c06f35f..f9e644f38 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -65,6 +65,22 @@ function renderUsageReports( lines.push( `- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /usage reset to spend`, ); + const credits = report.resetCredits?.credits; + if (credits) { + for (const credit of credits) { + if (credit.expiresAt) { + const expiryMs = Date.parse(credit.expiresAt); + if (!Number.isNaN(expiryMs)) { + const remaining = expiryMs - nowMs; + if (remaining > 0) { + lines.push(` expires in ${formatDuration(remaining)} (${credit.expiresAt.slice(0, 10)})`); + } else { + lines.push(` expired (${credit.expiresAt.slice(0, 10)})`); + } + } + } + } + } } if (report.limits.length === 0) { const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account"; From a0783fd8c43d7111433d825293a30a74b3ddf632 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Tue, 23 Jun 2026 13:37:31 -0700 Subject: [PATCH 2/4] fix: use live reset-credit count from detail endpoint (review feedback) --- packages/ai/src/usage/openai-codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 66fccbcab..2e5e9e841 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -488,6 +488,7 @@ export const openaiCodexUsageProvider: UsageProvider = { expiresAt: c.expiresAt, status: c.status, })); + resetCredits.availableCount = list.availableCount; } } catch (error) { ctx.logger?.warn("Codex reset credits detail fetch failed", { error: String(error) }); From f67234fc53671e2d00731dc49a9b59fcdd976d7a Mon Sep 17 00:00:00 2001 From: oldschoola Date: Tue, 23 Jun 2026 13:44:39 -0700 Subject: [PATCH 3/4] style: fix changelog formatting (fix-changelogs) --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3e63d4fe2..cfbd35300 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed all extension loading silently failing on the cross-compiled `omp-darwin-arm64` release binary (downloaded directly or via a Homebrew tap wrapper) because `__computeBunfsPackageRoot` mis-handled `import.meta.dir = "//root/omp-darwin-arm64"`. Bun 1.3.14 reports `/` for the compiled entry's `import.meta.dir`, but the pre-fix function joined `metaDir + "packages"` and produced `/root/omp-darwin-arm64/packages` — the binary basename was baked into every bunfs path, so the TypeBox/legacy-pi shims and every `@oh-my-pi/pi-*` package-root override failed `existsSync` validation and `resolveCanonicalPiSpecifier` fell through to a bunfs `Bun.resolveSync` that also could not find the module. The function now detects the bunfs-root + binary-basename shape (`path.basename(path.dirname(metaDir)) === "root"`) and strips the trailing binary segment by slicing the original `metaDir`; the production bunfs shim join path also preserves Bun's bunfs-native `//root` / `B:\~BUN\root` prefix that `path.join` would otherwise collapse. ([#3329](https://github.com/can1357/oh-my-pi/issues/3329)) + ## [16.1.16] - 2026-06-23 ### Breaking Changes From 4814c52433caa7b7085bf9f1e016c9d1e84a1447 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Tue, 23 Jun 2026 15:07:34 -0700 Subject: [PATCH 4/4] fix: sync resetCredits.availableCount from live endpoint even with empty credits Address codex P2 review: when listCodexResetCredits succeeds but returns an empty credits array (all expired/redeemed), the stale availableCount from /wham/usage was preserved instead of being updated to the live value. - Move availableCount sync outside the credits.length check into its own if (list) block, so the count is always synced from the detail endpoint. - Fix the test mock to route the rate-limit-reset-credits endpoint separately (matching the pattern used by the credits-with-expiry test), so the list endpoint returns a proper empty-credits response. --- packages/ai/src/usage/openai-codex.ts | 5 +++++ packages/ai/test/openai-codex-usage.test.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 2e5e9e841..9d5c21a46 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -488,6 +488,11 @@ export const openaiCodexUsageProvider: UsageProvider = { expiresAt: c.expiresAt, status: c.status, })); + } + // Always sync the live count from the detail endpoint — it may report + // fewer or zero available credits after expiry/redeem, even when the + // /wham/usage payload still has a stale count. + if (list) { resetCredits.availableCount = list.availableCount; } } catch (error) { diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 58d7b6e80..a89dbd5d0 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -128,13 +128,23 @@ describe("openai-codex usage parser", () => { }); it("surfaces rate_limit_reset_credits.available_count as report.resetCredits", async () => { - const payload = { ...makePayload(), rate_limit_reset_credits: { available_count: 1 } }; + const usagePayload = { ...makePayload(), rate_limit_reset_credits: { available_count: 1 } }; + const fetchImpl: FetchImpl = (async (url: string | URL | Request) => { + const path = typeof url === "string" ? url : url.toString(); + // Return an empty credits list for the detail endpoint — the count + // from /wham/usage should be synced from the live response. + const body = path.includes("rate-limit-reset-credits") ? { available_count: 1, credits: [] } : usagePayload; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as FetchImpl; const report = await openaiCodexUsageProvider.fetchUsage( { provider: "openai-codex", credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, }, - { fetch: fakeFetch(payload) }, + { fetch: fetchImpl }, ); expect(report?.resetCredits).toEqual({ availableCount: 1 }); });