diff --git a/packages/ai/src/usage/openai-codex-base-url.ts b/packages/ai/src/usage/openai-codex-base-url.ts new file mode 100644 index 000000000..9d3f90e9c --- /dev/null +++ b/packages/ai/src/usage/openai-codex-base-url.ts @@ -0,0 +1,15 @@ +import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex"; + +export function normalizeCodexBaseUrl(baseUrl?: string): string { + const fallback = CODEX_BASE_URL; + const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback; + const base = trimmed.replace(/\/+$/, ""); + const lower = base.toLowerCase(); + if ( + (lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) && + !lower.includes("/backend-api") + ) { + return `${base}/backend-api`; + } + return base; +} diff --git a/packages/ai/src/usage/openai-codex-reset.ts b/packages/ai/src/usage/openai-codex-reset.ts index 8efb86c6c..0b468d331 100644 --- a/packages/ai/src/usage/openai-codex-reset.ts +++ b/packages/ai/src/usage/openai-codex-reset.ts @@ -20,7 +20,7 @@ */ import type { FetchImpl } from "../types"; import { isRecord } from "../utils"; -import { normalizeCodexBaseUrl } from "./openai-codex"; +import { normalizeCodexBaseUrl } from "./openai-codex-base-url"; import { toNumber } from "./shared"; const RESET_CREDITS_PATH = "wham/rate-limit-reset-credits"; diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 9d5c21a46..fba1c8af4 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex"; +import { normalizeCodexBaseUrl } from "./openai-codex-base-url"; import type { CredentialRankingStrategy, UsageAmount, @@ -201,19 +201,6 @@ function parseResetCredits(payload: unknown): UsageResetCredits | undefined { return { availableCount: Math.max(0, Math.trunc(availableCount)) }; } -export function normalizeCodexBaseUrl(baseUrl?: string): string { - const fallback = CODEX_BASE_URL; - const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback; - const base = trimmed.replace(/\/+$/, ""); - const lower = base.toLowerCase(); - if ( - (lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) && - !lower.includes("/backend-api") - ) { - return `${base}/backend-api`; - } - return base; -} function buildCodexUsageUrl(baseUrl: string): string { const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 268ca04d2..247402dba 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -330,16 +330,19 @@ function formatAccountHeader( header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`); const credits = report.resetCredits?.credits; if (credits) { - const upcoming = credits + const expiries = 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); + .sort((a, b) => a.ms - b.ms); + const upcoming = expiries.find(c => c.ms > nowMs); if (upcoming) { header += chalk.dim( ` · soonest expires in ${formatDuration(upcoming.ms - nowMs)} (${upcoming.date.slice(0, 10)})`, ); + } else { + const lastExpired = expiries.at(-1); + if (lastExpired) header += chalk.dim(` · expired (${lastExpired.date.slice(0, 10)})`); } } } diff --git a/packages/coding-agent/test/modes/controllers/usage-command.test.ts b/packages/coding-agent/test/modes/controllers/usage-command.test.ts index 27e404ee3..7769ed268 100644 --- a/packages/coding-agent/test/modes/controllers/usage-command.test.ts +++ b/packages/coding-agent/test/modes/controllers/usage-command.test.ts @@ -65,4 +65,47 @@ describe("CommandController /usage", () => { expect(output).toContain("█"); expect(output).not.toContain("··········"); }); + + it("renders saved reset expiry lines for future and expired credits", async () => { + const present = vi.fn(); + const ctx = { + session: {}, + ui: { terminal: { columns: 100 } }, + present, + showWarning: vi.fn(), + showError: vi.fn(), + } as unknown as InteractiveModeContext; + const controller = new CommandController(ctx); + const now = Date.now(); + const dayMs = 24 * 60 * 60 * 1000; + const futureIso = new Date(now + 2 * dayMs).toISOString(); + const expiredIso = new Date(now - 2 * dayMs).toISOString(); + const reports: UsageReport[] = [ + { + provider: "openai-codex", + fetchedAt: now, + limits: [], + metadata: { email: "user@example.com" }, + resetCredits: { + availableCount: 2, + credits: [ + { id: "future-reset", expiresAt: futureIso }, + { id: "expired-reset", expiresAt: expiredIso }, + ], + }, + }, + ]; + + await controller.handleUsageCommand(reports); + + expect(present).toHaveBeenCalledTimes(1); + const firstCall = present.mock.calls[0]; + expect(firstCall).toBeDefined(); + const output = renderPresentedBlocks(firstCall?.[0]); + expect(output).toContain("Saved rate-limit resets"); + expect(output).toContain("user@example.com: 2 saved resets"); + expect(output).toContain(`expires in`); + expect(output).toContain(`(${futureIso.slice(0, 10)})`); + expect(output).toContain(`expired (${expiredIso.slice(0, 10)})`); + }); }); diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index 490f2d688..5d8cb1cd6 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -213,6 +213,38 @@ describe("formatUsageBreakdown", () => { expect(disclaimerIdx).toBeLessThan(firstLimitIdx); }); + it("renders saved reset expiry state for future and expired credits", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const reports: UsageReport[] = [ + { + provider: "openai-codex", + fetchedAt: now, + limits: [], + metadata: { email: "future@example.test" }, + resetCredits: { + availableCount: 1, + credits: [{ id: "future-reset", expiresAt: "2026-01-03T00:00:00.000Z" }], + }, + }, + { + provider: "openai-codex", + fetchedAt: now, + limits: [], + metadata: { email: "expired@example.test" }, + resetCredits: { + availableCount: 1, + credits: [{ id: "expired-reset", expiresAt: "2025-12-30T00:00:00.000Z" }], + }, + }, + ]; + + const text = stripVTControlCharacters(formatUsageBreakdown(reports, [], now)); + expect(text).toContain("future@example.test"); + expect(text).toContain("soonest expires in 2d (2026-01-03)"); + expect(text).toContain("expired@example.test"); + expect(text).toContain("expired (2025-12-30)"); + }); + it("deduplicates identical per-limit notes across accounts sharing a window", () => { const note = "Overage requests: 5"; const reports = [