diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index ca890fef1..719ec74dc 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -689,6 +689,20 @@ export interface ResetCreditRedeemOutcome { creditId?: string; } +/** One stored account's live saved-reset status, from {@link AuthStorage.listResetCredits}. */ +export interface ResetCreditAccountStatus { + credentialId?: number; + accountId?: string; + email?: string; + /** Resets redeemable for this account right now (live, not cached). */ + availableCount: number; + credits: CodexResetCredit[]; + /** Whether this is the given session's active account. */ + active: boolean; + /** Set when the account's token refresh or list call failed. */ + error?: string; +} + function isAbortSignalOption( value: InvalidateCredentialMatchingOptions | AbortSignal | undefined, ): value is AbortSignal { @@ -3620,6 +3634,53 @@ export class AuthStorage { ); } + /** + * List saved rate-limit resets for every stored OAuth account of `provider` + * (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route. + * + * This deliberately bypasses the usage-report cache: `/wham/usage` is + * IP-rate-limited and may serve stale (or pre-feature) snapshots when many + * accounts are polled, which would hide redeemable credits. One entry per + * account, with the session's active account flagged and unreachable + * accounts carrying an `error`. + */ + async listResetCredits(options?: { + provider?: string; + sessionId?: string; + baseUrlResolver?: (provider: string) => string | undefined; + signal?: AbortSignal; + }): Promise { + const provider = options?.provider ?? "openai-codex"; + const accesses = await this.getOAuthAccesses(provider); + if (accesses.length === 0) return []; + const baseUrl = options?.baseUrlResolver?.(provider); + const activeId = this.getOAuthAccountIdentity(provider, options?.sessionId); + return Promise.all( + accesses.map(async (access): Promise => { + const active = + !!activeId && + ((!!activeId.accountId && activeId.accountId === access.accountId) || + (!!activeId.email && activeId.email === access.email)); + const base = { + credentialId: access.credentialId, + accountId: access.accountId, + email: access.email, + active, + }; + if (!access.ok) return { ...base, availableCount: 0, credits: [], error: access.error }; + const list = await listCodexResetCredits({ + accessToken: access.accessToken, + accountId: access.accountId, + baseUrl, + fetch: this.#usageFetch, + signal: options?.signal, + }); + if (!list) return { ...base, availableCount: 0, credits: [], error: "Failed to load saved resets" }; + return { ...base, availableCount: list.availableCount, credits: list.credits }; + }), + ); + } + /** * Redeem one saved rate-limit reset (OpenAI Codex "saved resets") for a * specific stored account. diff --git a/packages/coding-agent/src/modes/components/reset-usage-selector.ts b/packages/coding-agent/src/modes/components/reset-usage-selector.ts index bc6df93c2..990616ec3 100644 --- a/packages/coding-agent/src/modes/components/reset-usage-selector.ts +++ b/packages/coding-agent/src/modes/components/reset-usage-selector.ts @@ -56,8 +56,14 @@ export class ResetUsageSelectorComponent extends Container { if (!account) continue; const isSelected = i === this.#selectedIndex; const redeemable = account.availableCount > 0; - const countLabel = `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`; - const countText = redeemable ? theme.fg("success", countLabel) : theme.fg("dim", countLabel); + const countLabel = account.error + ? account.error + : `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`; + const countText = account.error + ? theme.fg("error", countLabel) + : redeemable + ? theme.fg("success", countLabel) + : theme.fg("dim", countLabel); const activeTag = account.active ? theme.fg("muted", " (active)") : ""; if (isSelected) { const name = redeemable ? theme.fg("accent", account.label) : theme.fg("dim", account.label); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 32d2d7329..208fa071a 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1,5 +1,4 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { UsageReport } from "@oh-my-pi/pi-ai"; import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; @@ -32,10 +31,9 @@ import type { ResetCreditRedeemOutcome } from "../../session/auth-storage"; import { type SessionInfo, SessionManager } from "../../session/session-manager"; import { FileSessionStorage } from "../../session/session-storage"; import { - buildResetUsageAccounts, - CODEX_PROVIDER_ID, describeRedeemOutcome, type ResetUsageAccount, + toResetUsageAccounts, } from "../../slash-commands/helpers/reset-usage"; import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { @@ -1102,21 +1100,25 @@ export class SelectorController { async showResetUsageSelector(): Promise { const session = this.ctx.session; - let reports: UsageReport[] | null = null; + this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true }); + let statuses: Awaited>; try { - reports = await session.fetchUsageReports(); - } catch { - this.ctx.showError("Could not load usage data to find saved resets."); + statuses = await session.listResetCredits(); + } catch (error) { + this.ctx.showError(`Could not load saved resets: ${error instanceof Error ? error.message : String(error)}`); return; } - const active = session.modelRegistry.authStorage.getOAuthAccountIdentity(CODEX_PROVIDER_ID, session.sessionId); - const accounts = buildResetUsageAccounts(reports, active); + const accounts = toResetUsageAccounts(statuses); if (accounts.length === 0) { this.ctx.showStatus("No Codex accounts found. Use /login to add one."); return; } if (!accounts.some(account => account.availableCount > 0)) { - this.ctx.showStatus("No saved rate-limit resets available to spend right now."); + this.ctx.showStatus( + accounts.some(account => account.error) + ? "No saved resets available — some accounts couldn't be reached (try /login)." + : "No saved rate-limit resets available to spend right now.", + ); return; } this.showSelector(done => { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 31aa03a90..0cb1c53e2 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -73,6 +73,7 @@ import type { Model, ProviderResponseMetadata, ProviderSessionState, + ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget, ServiceTier, @@ -10153,6 +10154,19 @@ export class AgentSession { }); } + /** + * List saved Codex rate-limit resets per stored account, fetched live from + * the dedicated credits endpoint (bypasses the usage cache). Powers the + * `/reset-usage` account selector. + */ + async listResetCredits(signal?: AbortSignal): Promise { + return this.#modelRegistry.authStorage.listResetCredits({ + sessionId: this.sessionId, + baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), + signal, + }); + } + /** * Estimate context tokens from messages, using the last assistant usage when available. */ diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index 944212900..405f4d5ce 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -14,6 +14,7 @@ export type { CredentialOriginKind, OAuthAccountIdentity, OAuthCredential, + ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget, SerializedAuthStorage, diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 891669ece..195eafaf5 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -2,7 +2,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; -import type { UsageReport } from "@oh-my-pi/pi-ai"; import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug"; import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; @@ -31,11 +30,11 @@ import { formatDuration } from "./helpers/format"; import { createMarketplaceManager } from "./helpers/marketplace-manager"; import { handleMcpAcp } from "./helpers/mcp"; import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse"; +import { describeRedeemOutcome, toResetUsageAccounts } from "./helpers/reset-usage"; import { handleSshAcp } from "./helpers/ssh"; import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard"; import { handleTodoAcp } from "./helpers/todo"; import { buildUsageReportText } from "./helpers/usage-report"; -import { buildResetUsageAccounts, describeRedeemOutcome } from "./helpers/reset-usage"; import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "./marketplace-install-parser"; import type { BuiltinSlashCommand, @@ -611,15 +610,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ allowArgs: true, handle: async (command, runtime) => { const { session } = runtime; - let reports: UsageReport[] | null = null; + let accounts: ReturnType; try { - reports = await session.fetchUsageReports(); - } catch { - await runtime.output("Could not load usage data to find saved resets."); + accounts = toResetUsageAccounts(await session.listResetCredits()); + } catch (error) { + await runtime.output( + `Could not load saved resets: ${error instanceof Error ? error.message : String(error)}`, + ); return commandConsumed(); } - const active = session.modelRegistry.authStorage.getOAuthAccountIdentity("openai-codex", session.sessionId); - const accounts = buildResetUsageAccounts(reports, active); if (accounts.length === 0) { await runtime.output("No Codex accounts found. Use /login to add one."); return commandConsumed(); @@ -628,7 +627,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ if (!arg) { const lines = ["Saved Codex rate-limit resets:"]; for (const account of accounts) { - lines.push(`- ${account.label}: ${account.availableCount} available${account.active ? " (active)" : ""}`); + const detail = account.error ? `unavailable (${account.error})` : `${account.availableCount} available`; + lines.push(`- ${account.label}: ${detail}${account.active ? " (active)" : ""}`); } lines.push("", "Spend one with `/reset-usage ` or `/reset-usage active`."); await runtime.output(lines.join("\n")); diff --git a/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts b/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts index 79a888238..fba3780cb 100644 --- a/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts +++ b/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts @@ -1,10 +1,9 @@ /** - * Shared helpers for the `/reset-usage` command (TUI selector + ACP) and - * auto-redeem: turn usage reports into a per-account list of redeemable saved - * rate-limit resets, and map a redeem outcome code to a human message. + * Shared helpers for the `/reset-usage` command (TUI selector + ACP): turn the + * live per-account reset-credit status into selector rows, and map a redeem + * outcome code to a human message. */ -import type { UsageReport } from "@oh-my-pi/pi-ai"; -import type { OAuthAccountIdentity, ResetCreditRedeemOutcome, ResetCreditTarget } from "../../session/auth-storage"; +import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget } from "../../session/auth-storage"; export const CODEX_PROVIDER_ID = "openai-codex"; @@ -18,36 +17,32 @@ export interface ResetUsageAccount { target: ResetCreditTarget; /** Whether this is the session's active Codex account. */ active: boolean; + /** Set when this account could not be reached (token/list failure). */ + error?: string; } /** - * Build the per-account reset list from usage reports (Codex only). Sorted with - * the active account first, then most-credits, then label. + * Map live per-account reset status to selector rows. Sorted with the active + * account first, then most-credits, then label. */ -export function buildResetUsageAccounts( - reports: UsageReport[] | null | undefined, - active?: OAuthAccountIdentity, -): ResetUsageAccount[] { - const accounts = (reports ?? []) - .filter(report => report.provider === CODEX_PROVIDER_ID) - .map(report => { - const accountId = typeof report.metadata?.accountId === "string" ? report.metadata.accountId : undefined; - const email = typeof report.metadata?.email === "string" ? report.metadata.email : undefined; - const isActive = - !!active && - ((!!active.accountId && active.accountId === accountId) || (!!active.email && active.email === email)); - return { - label: email ?? accountId ?? "account", - availableCount: report.resetCredits?.availableCount ?? 0, - target: { accountId, email } satisfies ResetCreditTarget, - active: isActive, - }; +export function toResetUsageAccounts(statuses: ResetCreditAccountStatus[]): ResetUsageAccount[] { + return statuses + .map(status => ({ + label: status.email ?? status.accountId ?? "account", + availableCount: status.availableCount, + target: { + credentialId: status.credentialId, + accountId: status.accountId, + email: status.email, + } satisfies ResetCreditTarget, + active: status.active, + error: status.error, + })) + .sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1; + if (a.availableCount !== b.availableCount) return b.availableCount - a.availableCount; + return a.label.localeCompare(b.label); }); - return accounts.sort((a, b) => { - if (a.active !== b.active) return a.active ? -1 : 1; - if (a.availableCount !== b.availableCount) return b.availableCount - a.availableCount; - return a.label.localeCompare(b.label); - }); } /** Human-facing summary of a redeem outcome for status lines and ACP output. */