/** * Pool-wide planner for spending saved OpenAI Codex rate-limit resets, plus * the process-wide coordinator that serializes attempts. * * A saved reset is a scarce, ~monthly credit — but it is also perishable: * every credit carries an `expiresAt`, and an expired credit is worth exactly * nothing. The planner therefore balances two failure modes instead of only * one: wasting a credit on a window that would have healed anyway, and letting * a credit die unspent. Two rules, evaluated over EVERY stored Codex account * (not just the session's active one): * * - `expiring-credit` (salvage; any trigger): the account's soonest available * credit expires within `salvageHorizonMs` and either chat window is at least * {@link SALVAGE_MIN_USED_FRACTION} used, so redeeming restores real quota. * As a last chance, credits expiring within {@link IMMINENT_RESET_EXPIRY_MS} * are attempted even with a zero/shorter horizon or zero/unknown usage. * This fallback ignores non-terminal deferrals, but keeps live credit * eligibility, terminal-attempt dedupe, and the account cooldown. It still * needs consent, except that a host with no prompt UI spends it while * auto-redeem is unset (see {@link headlessApprovedResetActions}). * The `keepCredits` reserve is deliberately ignored here — reserving a * credit that is about to expire preserves nothing. If the window resets * naturally before the credit expires, broader salvage skips the mostly-free * window until the last-chance horizon. The backend may * refuse a partial-usage consume with `nothing_to_reset`; that outcome is * NON-terminal — the episode is deferred (not buried), so the credit is * retried as usage grows or a window exhausts before expiry. * - `blocked-account` (restore; `blocked` trigger only): a live 429 blocked * the turn and no sibling credential could take over, so the pool is dry. * Candidates are accounts with at least one genuinely exhausted normalized * chat window — 5h or weekly. A banked reset clears the account's * chat rate limits generally, not just the weekly window: OpenAI's own * client consumes one for a 5h-only block while the weekly window still has * ~80% headroom (openai/codex#28525). The natural unblock is the LATEST * reset among the exhausted windows (the account stays blocked until every * one rolls over) and must be far enough away to justify the spend, with * credits above the reserve, on an account the session may use (its account * pool). One candidate is redeemed — active account first, then the account * whose credit dies soonest — and the redeem clears its credential blocks so * the retry's re-rank picks it up. * * TRIGGERS: `blocked` runs from the usage-limit branch of the retry pipeline * after sibling switch fails, on force-refreshed reports (the cached snapshot * predates the 429 that got us there). `sweep` piggybacks on every successful * usage-report fetch — the status line polls every 5 minutes while the TUI is * open — so expiring credits are caught even when nothing is blocked. * * THE DECISION-2 TRAP (status MUST NOT be used to find the blocker): * `openai-codex.ts` applies the top-level `rate_limit.limit_reached` flag to * BOTH the primary (5h) and secondary (weekly) `buildUsageLimit` calls, so when * an account is blocked, *both* limit entries carry `status: "exhausted"` * regardless of which window is actually at 100%. Only `amount.usedFraction` * disambiguates. This module selects the base chat entries by their stable * limit ids, then classifies the actual window from normalized window metadata * before applying duration-specific policy. Whether a short wait (e.g. a * 5h-only block) is worth a credit is the `minBlockedMinutes` knob's call, * not hardcoded fiat. * * All of this is pure — no fetches, no IO. The only stateful piece is the * {@link CodexAutoRedeemCoordinator} container, whose read-only views are * passed in so the planner itself stays deterministic. */ import type { OAuthAccountIdentity, ResetCreditAccountStatus, ResetCreditTarget, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai"; import type { ResetAutoRedeemMode } from "./settings.js"; /** A chat window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */ export declare const WINDOW_EXHAUSTED_MIN_FRACTION = 0.999; /** A weekly reset can never be more than one window length (7d) away; +1h slack for skew. */ export declare const MAX_PLAUSIBLE_WEEKLY_REMAINING_MS: number; /** A 5h reset can never be more than one window length (5h) away; +1h slack for skew. */ export declare const MAX_PLAUSIBLE_PRIMARY_REMAINING_MS: number; /** Shared last-chance expiry horizon; bypasses salvage usage thresholds, non-terminal deferrals and, with no prompt UI, unset consent; never cooldown. */ export declare const IMMINENT_RESET_EXPIRY_MS: number; /** Below this usage on BOTH chat windows, non-imminent salvage restores too little to bother. */ export declare const SALVAGE_MIN_USED_FRACTION = 0.25; /** Retry spacing after a non-terminal consume outcome, except during imminent-expiry salvage. */ export declare const REDEEM_RETRY_DEFER_MS: number; /** Report must be no older than the 5-min usage cache TTL plus slack. */ export declare const REPORT_FRESHNESS_MS: number; /** Per-account cooldown that catches attempt-key drift across a minute boundary. */ export declare const ATTEMPT_COOLDOWN_MS = 60000; /** Minute bucket for attempt keys, absorbing `reset_after_seconds`/expiry jitter. */ export declare const DEBOUNCE_BUCKET_MS = 60000; /** Floor between salvage sweeps; dedupe keys make sweeps idempotent, this just avoids useless re-planning. */ export declare const SWEEP_MIN_INTERVAL_MS = 60000; export declare function shouldEvaluateCodexAutoRedeem(mode: ResetAutoRedeemMode): boolean; export declare function shouldPromptCodexAutoRedeem(mode: ResetAutoRedeemMode): boolean; /** * Planned spends a host with no prompt UI may make while auto-redeem is unset: * only a credit expiring within {@link IMMINENT_RESET_EXPIRY_MS} qualifies, * whether a salvage or a restore spends it, since nobody can be asked before it * is lost. The approved target names that credit, so redeem cannot pick a later one. */ export declare function headlessApprovedResetActions>(actions: readonly T[]): readonly T[]; /** Planner settings from a provider's `codexResets.*` or `claudeResets.*` group. */ export declare function resetPlanSettings(cfg: { autoRedeem: ResetAutoRedeemMode; minBlockedMinutes: number; keepCredits: number; salvageHorizonHours: number; }): CodexResetPlanInput["settings"]; /** What woke the planner. `sweep` may only salvage; `blocked` may also restore. */ export type CodexResetTrigger = "blocked" | "sweep"; /** Why one account produced no action (or, with `accountKey: "*"`, a whole rule was off). */ export type CodexResetSkipReason = "disabled" | "wrong-provider" | "spark-model" | "no-identity" | "stale-report" | "outside-account-pool" | "not-limit-reached" | "no-exhausted-window" | "deferred" | "no-reset-time" | "reset-too-soon" | "reset-implausible" | "credits-unknown" | "no-credits" | "reserve" | "no-expiring-credit" | "window-mostly-free" | "already-attempted" | "cooldown"; export interface CodexResetPlanInput { nowMs: number; trigger: CodexResetTrigger; /** `this.model.provider` — gates the `blocked-account` rule only. */ provider: string; /** `this.model.id` — gates the `blocked-account` rule only. */ modelId: string; settings: { enabled: boolean; /** `blocked-account`: skip when the natural unblock is closer than this. */ minBlockedMinutes: number; /** `blocked-account`: never spend below this many remaining credits. */ keepCredits: number; /** `expiring-credit`: broader salvage window; `<= 0` leaves only the five-minute fallback. */ salvageHorizonMs: number; }; /** Active account (marks the preferred restore candidate); may be undefined. */ identity: OAuthAccountIdentity | undefined; /** `blocked-account`: whether a stored credential may serve the blocked session; absent allows every account. */ permitsCredential?: (credentialId: number) => boolean; /** Usage reports for ALL stored accounts (one per account for Codex). */ reports: UsageReport[] | null; attemptedKeys: ReadonlySet; /** Episodes parked by a non-terminal consume outcome, keyed by attempt key (epoch ms). */ deferredUntilByKey: ReadonlyMap; lastAttemptAtByAccount: ReadonlyMap; /** * Live 429 evidence for the ACTIVE account: absolute epoch ms when the * provider said the account unblocks, captured before eligibility IO so * slow refreshes cannot drift it. A pre-block report, or a synthetic report * built from a uniquely identified live credit status, may use this timing. */ activeBlockUnblockAtMs?: number; } /** One credit to spend. `redeemResetCredit` picks the account's soonest-expiring credit. */ export interface CodexResetAction { reason: "blocked-account" | "expiring-credit"; target: ResetCreditTarget; accountKey: string; /** Once-per-episode dedupe key; record in `attemptedKeys` BEFORE consuming. */ attemptKey: string; /** Human label for notices/prompts (email preferred). */ label: string; /** Redeemable credits in the authoritative live status. */ availableCount?: number; weeklyUsedFraction?: number; /** `blocked-account`: ms until the natural unblock (latest exhausted-window reset). */ remainingMs?: number; /** `blocked-account`: the exhausted chat windows a redeem would clear. */ blockedWindows?: ("5h" | "weekly")[]; /** `expiring-credit`: the fuller chat window a redeem restores (for messaging). */ salvageWindow?: "5h" | "weekly"; /** `expiring-credit`: used fraction of {@link CodexResetAction.salvageWindow}. */ salvageUsedFraction?: number; /** Remaining lifetime, at planning time, of the soonest available credit (the one a redeem spends). */ expiresInMs?: number; /** Id of the credit {@link CodexResetAction.expiresInMs} describes, when the listing reports one. */ creditId?: string; /** True when this is the session's active account. */ active: boolean; } export interface CodexResetSkip { /** Normalized account key, or `"*"` for a rule-wide gate. */ accountKey: string; rule: "blocked-account" | "expiring-credit" | "account"; reason: CodexResetSkipReason; } export interface CodexResetPlan { /** At most one `blocked-account` action (first), then salvages by soonest expiry. */ actions: CodexResetAction[]; /** Diagnostics for `logger.debug` and tests. */ skipped: CodexResetSkip[]; } type CodexChatWindow = "5h" | "weekly"; export interface CodexChatWindowSnapshot { limit: UsageLimit; window: CodexChatWindow; usedFraction: number | undefined; resetsAt: number | undefined; plausibleMs: number; } /** The chat window the `expiring-credit` rule measures for one Codex usage report. */ export declare function fullestCodexChatWindow(report: UsageReport): CodexChatWindowSnapshot | undefined; /** * Plan which saved Codex resets to spend right now. Pure: a function of the * snapshot inputs only. Callers execute the returned actions in order, * re-checking `attemptedKeys` immediately before each consume. */ export declare function planCodexResetRedemptions(input: CodexResetPlanInput): CodexResetPlan; /** * Fence saved-reset spending by upstream provider, organization, and account, * never by a credential row id that another store may reuse. */ export declare function resetAccountLockKey(identity: { provider: string; accountId?: string; email?: string; orgId?: string; }): string | undefined; /** One attempt per (account, weekly-reset-minute) block episode. */ export declare function blockedAttemptKey(accountKey: string, weeklyResetsAtMs: number): string; /** One attempt per (account, credit-expiry-minute) salvage episode. */ export declare function salvageAttemptKey(accountKey: string, creditExpiresAtMs: number): string; /** * Overlay LIVE per-account credit state (from the dedicated * `rate-limit-reset-credits` route) onto usage reports before a blocked pass. * * `/wham/usage` credit counts can be stale or pre-feature, and the usage * provider only consults the live detail endpoint when the usage payload * already reports a POSITIVE count — a stale ZERO is never corrected there. * Live data replaces the report's credit block wholesale; accounts with no * live row get the block stripped. For blocked recovery, the active live row * may synthesize a minimal report carrying its durable credential id when * usage is missing/stale; the 429 supplies timing, never credit eligibility. */ export declare function overlayLiveResetCredits(reports: UsageReport[] | null, statuses: readonly ResetCreditAccountStatus[], options?: { synthesizeActive?: boolean; nowMs?: number; }): UsageReport[] | null; /** * Whether a consume outcome permanently settles its episode. Terminal codes * either spent the credit (`reset`), or prove this credit can never be spent * (`already_redeemed`, `no_credit` — a live listing that really had nothing). * Everything else — `nothing_to_reset` (limits not constrained enough right * now), `credit_list_failed` (flaky listing), thrown transport failures, * unknown codes — leaves the credit banked: the executor releases the attempt * key and * defers the episode by {@link REDEEM_RETRY_DEFER_MS} instead of burying a * live credit for the rest of the process. */ export declare function isTerminalRedeemOutcome(code: string): boolean; /** A confirmed reset, or a safe eligibility read that must wait before retrying. */ export interface ResetRecoveryResult { restored: boolean; retryAfterMs?: number; /** Credentials whose reset the pass spent or adopted from a peer. */ restoredCredentialIds?: number[]; /** The session's account pool excluded a restore candidate or dropped a planned restore. */ poolLimited?: boolean; } /** * Process-wide (NOT per-session) coordinator state. Parallel sessions share * provider accounts and must not race a double-spend; Claude reuses the same * attempt/defer/in-flight machinery rather than maintaining a second pipeline. * * - `attemptedKeys`: one attempt per episode key — recorded before calling the * consume so exceptions can't re-enter. Non-terminal outcomes (see * {@link isTerminalRedeemOutcome}) release the key again and park the * episode in `deferredUntilByKey` instead. * - `deferredUntilByKey`: earliest retry time for episodes whose consume was * refused non-terminally (`nothing_to_reset`, transport failure). * - `lastAttemptAtByAccount`: per-account cooldown timestamps (epoch ms), * catching attempt-key drift across a minute boundary. * - `inFlightByAccount`: serializes blocked passes per account — a second * session for the same account adopts the in-flight promise instead of * starting a second consume, unless that pass could not serve its account pool. * - `sweepInFlight` / `lastSweepAt` / `sweepPromise`: re-entrancy guard, floor, * and settlement handle for the combined provider salvage sweep (a redeem refreshes usage, * which would recurse into a sweep; the promise lets tests and diagnostics * await a fire-and-forget sweep instead of polling). * - `notifiedKeys`: headless "run /usage reset" notices already emitted, so a * 5-minute sweep cadence can't spam the transcript. */ export interface CodexAutoRedeemCoordinator { attemptedKeys: Set; deferredUntilByKey: Map; lastAttemptAtByAccount: Map; /** Alternate lock root for isolated sessions (production uses the shared agent database path). */ resetLockPath?: string; inFlightByAccount: Map>; sweepInFlight: boolean; lastSweepAt: number; /** Settlement of the most recently scheduled sweep (never rejects). */ sweepPromise: Promise | undefined; notifiedKeys: Set; } /** Fresh, empty coordinator: backs the process-wide default; inject one per test for isolation. */ export declare function createCodexAutoRedeemCoordinator(): CodexAutoRedeemCoordinator; export declare const defaultCodexAutoRedeemCoordinator: CodexAutoRedeemCoordinator; export {};