feat(coding-agent): added live saved-reset listing for all Codex accounts
- Added AuthStorage.listResetCredits to query each stored Codex account from the reset-credits endpoint and return live availability plus active or error state. - Exposed the new status fetch through AgentSession and switched reset-usage selectors and commands to consume it via toResetUsageAccounts. - Updated reset-usage UI and slash-command output to show per-account errors and updated empty-state messaging when resets could not be loaded.
This commit is contained in:
@@ -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<ResetCreditAccountStatus[]> {
|
||||
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<ResetCreditAccountStatus> => {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
const session = this.ctx.session;
|
||||
let reports: UsageReport[] | null = null;
|
||||
this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
|
||||
let statuses: Awaited<ReturnType<typeof session.listResetCredits>>;
|
||||
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 => {
|
||||
|
||||
@@ -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<ResetCreditAccountStatus[]> {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
CredentialOriginKind,
|
||||
OAuthAccountIdentity,
|
||||
OAuthCredential,
|
||||
ResetCreditAccountStatus,
|
||||
ResetCreditRedeemOutcome,
|
||||
ResetCreditTarget,
|
||||
SerializedAuthStorage,
|
||||
|
||||
@@ -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<SlashCommandSpec> = [
|
||||
allowArgs: true,
|
||||
handle: async (command, runtime) => {
|
||||
const { session } = runtime;
|
||||
let reports: UsageReport[] | null = null;
|
||||
let accounts: ReturnType<typeof toResetUsageAccounts>;
|
||||
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<SlashCommandSpec> = [
|
||||
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 <account email>` or `/reset-usage active`.");
|
||||
await runtime.output(lines.join("\n"));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user