diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 801bb6e91..21cf48d63 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -116,6 +116,10 @@ - Changed the GitLab Duo Agent `goal` transcript to render replayed tool calls as past-tense `{args}` records and tool results as `` / ``, replacing the prior `{"name":…,"arguments":…}` / `` markers. The model was mimicking the old `{name,arguments}` shape as its own emittable text instead of using the structured tool-use channel, because the historical-record markers were byte-identical to a plausible live-call grammar. The new form reads as a past record (a call that already ran), drops the `{name,arguments}` wrapper (the tool name moves onto the tag, args ride the body), and omits the tool name from the result header (call→result pair by adjacency). This also trims ~48 bytes per call/result pair in the worked example. - Changed the GitLab Duo Agent inline flow's system prompt to append a short history-note whenever the `goal` is a multi-turn ChatML transcript, telling the model the transcript's `<|im_start|>`/``/`` markers are a past record of already-executed turns and tool calls — not a syntax to emit — and to call tools only through its structured tool-use channel. Reframing the markers to past tense reduced but did not eliminate the model copying them as its own output; the explicit instruction closes the remaining gap. The note rides the system slot (not the goal), is appended only for multi-turn transcripts (a lone bare-text prompt has no markers), and lives in a static `.md` file imported as text. +### 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 07c842f35..05b66499d 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 c0cf013d7..166a616cc 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. */ @@ -202,8 +221,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..9d5c21a46 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,34 @@ 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, + })); + } + // 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) { + 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..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 }); }); @@ -149,4 +159,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 acfdf9b43..fabd48076 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,10 @@ - Changed the `inlineToolDescriptors` setting ("Inline Tool Descriptors") from a boolean to a three-way enum (`auto` | `on` | `off`), defaulting to `auto`. `auto` inlines tool descriptors into the system prompt (and strips them from provider tool schemas) only for Gemini models, leaving them in the schemas otherwise; `on`/`off` force the behavior regardless of model. Existing `true`/`false` configs migrate to `on`/`off`. - Replaced `as string | undefined` inline casts with `typeof` guards in the TUI usage renderer's account identity resolution (`formatAccountLabel`, `formatUnlimitedReportLabel`, reset-credits label, and unlimited-plan tier), so empty-string metadata values fall through to the next fallback instead of being displayed +### 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 `snapcompact` compaction silently falling back to an LLM summary when local preflight rejects the archive; manual and auto snapcompact now fail locally with the blocker instead of making provider calls. ([#3599](https://github.com/can1357/oh-my-pi/issues/3599)) @@ -156,6 +160,7 @@ - Fixed GitLab Duo Agent namespace/project discovery reading the original repo's git remote after a `/move`. The session's working directory is now resolved live (per LLM call) from the `SessionManager` instead of being captured when the agent was constructed, so moving the session re-scopes Duo workspace discovery to the new repository. - Fixed `omp auth-broker login gitlab-duo-agent` (and `--via`) hanging until timeout: the provider uses GitLab's fixed `vscode://` OAuth redirect, which never reaches the broker's local callback server, and `runLocalLogin` supplied no `onManualCodeInput` fallback. The broker login now offers the same paste-the-redirect-URL prompt the interactive sign-in uses, so credentials can be saved. + ## [16.1.16] - 2026-06-23 ### Breaking Changes diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index aaa5408d0..268ca04d2 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 db8ebd7b9..e543e0f37 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1604,6 +1604,23 @@ export 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 1fec09a75..40865efc0 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -75,6 +75,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";