mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 14:26:05 +00:00
Restores the Codex/OpenAI usage line in status by routing Codex-harness usage through the Codex app-server provider hook. Preserves configured app-server startup options, selected OpenAI/Codex auth profiles, weekly-window cadence, and Codex credit wording while skipping unsupported API-key usage probes. Fixes #91694.
774 lines
26 KiB
TypeScript
774 lines
26 KiB
TypeScript
/**
|
|
* Parses Codex account rate-limit payloads into user-facing usage summaries,
|
|
* reset hints, and enriched usage-limit error messages.
|
|
*/
|
|
import {
|
|
MAX_DATE_TIMESTAMP_MS,
|
|
resolveExpiresAtMsFromEpochSeconds,
|
|
} from "openclaw/plugin-sdk/number-runtime";
|
|
import {
|
|
clampPercent,
|
|
PROVIDER_LABELS,
|
|
type ProviderUsageSnapshot,
|
|
type UsageWindow,
|
|
} from "openclaw/plugin-sdk/provider-usage";
|
|
import { asFiniteNumber, parseStrictFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
|
|
|
const CODEX_LIMIT_ID = "codex";
|
|
const LIMIT_WINDOW_KEYS = ["primary", "secondary"] as const;
|
|
const ONE_SECOND_MS = 1000;
|
|
const ONE_MINUTE_MS = 60_000;
|
|
const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
|
|
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
|
|
const DAY_WINDOW_MINUTES = 24 * 60;
|
|
const WEEKLY_WINDOW_MINUTES = 7 * DAY_WINDOW_MINUTES;
|
|
const WEEKLY_RESET_GAP_MS = 3 * ONE_DAY_MS;
|
|
|
|
type LimitWindowKey = (typeof LIMIT_WINDOW_KEYS)[number];
|
|
|
|
type RateLimitReset = {
|
|
resetsAtMs: number;
|
|
usedPercent?: number;
|
|
windowDurationMins?: number;
|
|
};
|
|
|
|
type RateLimitWindowEntry = {
|
|
key: LimitWindowKey;
|
|
window: RateLimitReset;
|
|
};
|
|
|
|
/** Human-readable Codex account usage state derived from rate-limit snapshots. */
|
|
export type CodexAccountUsageSummary = {
|
|
usageLine?: string;
|
|
blocked: boolean;
|
|
blockedUntilMs?: number;
|
|
blockedUntilText?: string;
|
|
blockedResetRelative?: string;
|
|
blockingPeriod?: string;
|
|
blockingReason?: string;
|
|
};
|
|
|
|
/** Enriches Codex usage-limit failures with reset timing and recovery guidance. */
|
|
export function formatCodexUsageLimitErrorMessage(params: {
|
|
message?: string | null;
|
|
codexErrorInfo?: JsonValue | null;
|
|
rateLimits?: JsonValue;
|
|
nowMs?: number;
|
|
}): string | undefined {
|
|
const message = normalizeText(params.message);
|
|
if (!isCodexUsageLimitError(params.codexErrorInfo, message)) {
|
|
return undefined;
|
|
}
|
|
const nowMs = params.nowMs ?? Date.now();
|
|
const usageSummary = summarizeCodexAccountUsage(params.rateLimits, nowMs);
|
|
const blockingReset = selectBlockingRateLimitReset(params.rateLimits, nowMs);
|
|
const nextReset =
|
|
blockingReset ??
|
|
(usageSummary?.blocked ? undefined : selectNextRateLimitReset(params.rateLimits, nowMs));
|
|
const parts = ["You've reached your Codex subscription usage limit."];
|
|
let recoveryAction = "Wait until Codex becomes available";
|
|
if (nextReset) {
|
|
parts.push(`Next reset ${formatResetTime(nextReset.resetsAtMs, nowMs)}.`);
|
|
recoveryAction = "Wait until the reset time";
|
|
} else {
|
|
const codexRetryHint = extractCodexRetryHint(message);
|
|
if (codexRetryHint) {
|
|
parts.push(`Codex says to try again ${codexRetryHint}.`);
|
|
recoveryAction = "Wait until the retry time";
|
|
} else {
|
|
if (usageSummary?.blockingPeriod && usageSummary.blockingReason) {
|
|
parts.push(`Your ${usageSummary.blockingReason}.`);
|
|
}
|
|
parts.push("OpenClaw could not determine a reset time from Codex.");
|
|
}
|
|
}
|
|
parts.push(
|
|
`${recoveryAction}, use another Codex account if available, or switch to another configured model/provider.`,
|
|
);
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/** Detects usage-limit messages that need a fresh rate-limit query before display. */
|
|
export function shouldRefreshCodexRateLimitsForUsageLimitMessage(
|
|
message: string | null | undefined,
|
|
): boolean {
|
|
const text = normalizeText(message);
|
|
return Boolean(
|
|
text?.includes("You've reached your Codex subscription usage limit.") &&
|
|
!text.includes("Next reset "),
|
|
);
|
|
}
|
|
|
|
/** Formats compact summaries for raw Codex rate-limit snapshot payloads. */
|
|
export function summarizeCodexRateLimits(
|
|
value: JsonValue | undefined,
|
|
nowMs = Date.now(),
|
|
): string | undefined {
|
|
const snapshots = collectCodexRateLimitSnapshots(value).filter(snapshotHasDisplayableData);
|
|
if (snapshots.length === 0) {
|
|
return undefined;
|
|
}
|
|
const summaries = snapshots
|
|
.slice(0, 4)
|
|
.map((snapshot) => summarizeRateLimitSnapshot(snapshot, nowMs))
|
|
.filter((summary): summary is string => summary !== undefined);
|
|
return summaries.length > 0 ? summaries.join("; ") : undefined;
|
|
}
|
|
|
|
/** Returns true when a value contains any recognizable Codex rate-limit snapshots. */
|
|
export function hasCodexRateLimitSnapshots(value: JsonValue | undefined): boolean {
|
|
return collectCodexRateLimitSnapshots(value).length > 0;
|
|
}
|
|
|
|
/** Builds short account availability lines suitable for status surfaces. */
|
|
export function summarizeCodexAccountRateLimits(
|
|
value: JsonValue | undefined,
|
|
nowMs = Date.now(),
|
|
): string[] | undefined {
|
|
const summary = summarizeCodexAccountUsage(value, nowMs);
|
|
if (!summary) {
|
|
return undefined;
|
|
}
|
|
if (!summary.blocked) {
|
|
return ["Codex is available."];
|
|
}
|
|
return [
|
|
summary.blockedUntilText
|
|
? `Codex is paused until ${summary.blockedUntilText}.`
|
|
: "Codex is paused by a usage limit.",
|
|
summary.blockingReason
|
|
? `Your ${summary.blockingReason}.`
|
|
: "Your Codex usage limit is reached.",
|
|
];
|
|
}
|
|
|
|
/** Returns the reset timestamp for the currently blocking Codex usage limit. */
|
|
export function resolveCodexUsageLimitResetAtMs(
|
|
value: JsonValue | undefined,
|
|
nowMs = Date.now(),
|
|
): number | undefined {
|
|
return selectBlockingRateLimitReset(value, nowMs)?.resetsAtMs;
|
|
}
|
|
|
|
/** Summarizes account availability, blocking reason, and reset time from rate-limit data. */
|
|
export function summarizeCodexAccountUsage(
|
|
value: JsonValue | undefined,
|
|
nowMs = Date.now(),
|
|
): CodexAccountUsageSummary | undefined {
|
|
const snapshots = collectCodexRateLimitSnapshots(value).filter(snapshotHasDisplayableData);
|
|
if (snapshots.length === 0) {
|
|
return undefined;
|
|
}
|
|
const usageSnapshot = snapshots.find(isCodexLimitSnapshot) ?? snapshots[0];
|
|
const blockedSnapshots = snapshots.filter(snapshotHasLimitBlock);
|
|
const blockingSnapshot =
|
|
blockedSnapshots.find(isCodexLimitSnapshot) ?? blockedSnapshots[0] ?? undefined;
|
|
const blockingEntries = blockingSnapshot ? readWindowEntries(blockingSnapshot) : [];
|
|
const blockingWindowEntry = selectBlockingWindowEntry(blockingEntries, nowMs);
|
|
const blockingWindow = blockingWindowEntry?.window;
|
|
const blockingReset =
|
|
blockingWindow && blockingWindow.resetsAtMs > nowMs ? blockingWindow : undefined;
|
|
const blockingPeriod = formatBlockingLimitPeriod(blockingWindowEntry, blockingEntries);
|
|
const blockedUntilText = blockingReset
|
|
? formatAccountResetTime(blockingReset.resetsAtMs, nowMs)
|
|
: undefined;
|
|
const blockedResetRelative = blockingReset
|
|
? `in ${formatRelativeDuration(blockingReset.resetsAtMs - nowMs)}`
|
|
: undefined;
|
|
const blockingReason = blockingPeriod
|
|
? `${blockingPeriod} Codex usage limit is reached`
|
|
: blockingSnapshot
|
|
? "Codex usage limit is reached"
|
|
: undefined;
|
|
return {
|
|
usageLine: formatUsageLine(usageSnapshot),
|
|
blocked: Boolean(blockingSnapshot),
|
|
...(blockingReset ? { blockedUntilMs: blockingReset.resetsAtMs } : {}),
|
|
...(blockedUntilText ? { blockedUntilText } : {}),
|
|
...(blockedResetRelative ? { blockedResetRelative } : {}),
|
|
...(blockingPeriod ? { blockingPeriod } : {}),
|
|
...(blockingReason ? { blockingReason } : {}),
|
|
};
|
|
}
|
|
|
|
/** Converts Codex app-server rate-limit payloads into OpenAI/Codex usage windows. */
|
|
export function buildCodexAppServerUsageSnapshot(value: unknown): ProviderUsageSnapshot {
|
|
const snapshot = selectCodexProviderUsageSnapshot(value);
|
|
const entries = snapshot ? readWindowEntries(snapshot) : [];
|
|
const windows = entries
|
|
.map((entry) => readProviderUsageWindow(entry, entries))
|
|
.filter((window): window is UsageWindow => Boolean(window));
|
|
return {
|
|
provider: "openai",
|
|
displayName: PROVIDER_LABELS.openai,
|
|
windows,
|
|
...(snapshot ? { plan: resolveCodexProviderUsagePlan(snapshot) } : {}),
|
|
};
|
|
}
|
|
|
|
function isCodexUsageLimitError(
|
|
codexErrorInfo: JsonValue | null | undefined,
|
|
message: string | undefined,
|
|
): boolean {
|
|
if (codexErrorInfo === "usageLimitExceeded") {
|
|
return true;
|
|
}
|
|
if (typeof codexErrorInfo === "string") {
|
|
const normalized = codexErrorInfo.replace(/[_\s-]/gu, "").toLowerCase();
|
|
if (normalized === "usagelimitexceeded") {
|
|
return true;
|
|
}
|
|
}
|
|
return Boolean(message?.toLowerCase().includes("usage limit"));
|
|
}
|
|
|
|
function selectNextRateLimitReset(
|
|
value: JsonValue | undefined,
|
|
nowMs: number,
|
|
): RateLimitReset | undefined {
|
|
const windows = collectCodexRateLimitSnapshots(value).flatMap((snapshot) =>
|
|
LIMIT_WINDOW_KEYS.flatMap((key) => readRateLimitWindow(snapshot, key) ?? []),
|
|
);
|
|
const futureWindows = windows.filter((window) => window.resetsAtMs > nowMs);
|
|
if (futureWindows.length === 0) {
|
|
return undefined;
|
|
}
|
|
const exhaustedWindows = futureWindows.filter(
|
|
(window) => window.usedPercent !== undefined && window.usedPercent >= 100,
|
|
);
|
|
const candidates = exhaustedWindows.length > 0 ? exhaustedWindows : futureWindows;
|
|
return candidates.toSorted((left, right) => left.resetsAtMs - right.resetsAtMs)[0];
|
|
}
|
|
|
|
function selectBlockingRateLimitReset(
|
|
value: JsonValue | undefined,
|
|
nowMs: number,
|
|
): RateLimitReset | undefined {
|
|
const snapshots = collectCodexRateLimitSnapshots(value);
|
|
const blockedSnapshots = snapshots.filter(snapshotHasLimitBlock);
|
|
const blockingSnapshot =
|
|
blockedSnapshots.find(isCodexLimitSnapshot) ?? blockedSnapshots[0] ?? undefined;
|
|
return blockingSnapshot ? selectSnapshotBlockingReset(blockingSnapshot, nowMs) : undefined;
|
|
}
|
|
|
|
function summarizeRateLimitSnapshot(snapshot: JsonObject, nowMs: number): string | undefined {
|
|
const label = formatLimitLabel(snapshot);
|
|
const windows = LIMIT_WINDOW_KEYS.flatMap((key) => {
|
|
const window = readRateLimitWindow(snapshot, key);
|
|
return window ? [formatRateLimitWindow(key, window, nowMs)] : [];
|
|
});
|
|
const reachedType =
|
|
readString(snapshot, "rateLimitReachedType") ?? readString(snapshot, "rate_limit_reached_type");
|
|
const suffix = reachedType ? ` (${formatReachedType(reachedType)})` : "";
|
|
if (windows.length > 0) {
|
|
return `${label}: ${windows.join(" · ")}${suffix}`;
|
|
}
|
|
if (reachedType) {
|
|
return `${label}: ${formatReachedType(reachedType)}`;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function collectCodexRateLimitSnapshots(value: JsonValue | undefined): JsonObject[] {
|
|
const snapshots: JsonObject[] = [];
|
|
const seen = new Set<string>();
|
|
collectRateLimitSnapshots(value, snapshots, seen);
|
|
return snapshots;
|
|
}
|
|
|
|
function collectRateLimitSnapshots(
|
|
value: JsonValue | undefined,
|
|
snapshots: JsonObject[],
|
|
seen: Set<string>,
|
|
): void {
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
collectRateLimitSnapshots(entry, snapshots, seen);
|
|
}
|
|
return;
|
|
}
|
|
if (!isJsonObject(value)) {
|
|
return;
|
|
}
|
|
if (isRateLimitSnapshot(value)) {
|
|
addRateLimitSnapshot(value, snapshots, seen);
|
|
return;
|
|
}
|
|
const byLimitId = value.rateLimitsByLimitId;
|
|
if (isJsonObject(byLimitId)) {
|
|
for (const key of sortedRateLimitKeys(Object.keys(byLimitId))) {
|
|
collectRateLimitSnapshots(byLimitId[key], snapshots, seen);
|
|
}
|
|
}
|
|
const snakeByLimitId = value.rate_limits_by_limit_id;
|
|
if (isJsonObject(snakeByLimitId)) {
|
|
for (const key of sortedRateLimitKeys(Object.keys(snakeByLimitId))) {
|
|
collectRateLimitSnapshots(snakeByLimitId[key], snapshots, seen);
|
|
}
|
|
}
|
|
collectRateLimitSnapshots(value.rateLimits, snapshots, seen);
|
|
collectRateLimitSnapshots(value.rate_limits, snapshots, seen);
|
|
collectRateLimitSnapshots(value.data, snapshots, seen);
|
|
collectRateLimitSnapshots(value.items, snapshots, seen);
|
|
}
|
|
|
|
function sortedRateLimitKeys(keys: string[]): string[] {
|
|
return keys.toSorted((left, right) => {
|
|
if (left === CODEX_LIMIT_ID) {
|
|
return -1;
|
|
}
|
|
if (right === CODEX_LIMIT_ID) {
|
|
return 1;
|
|
}
|
|
return left.localeCompare(right);
|
|
});
|
|
}
|
|
|
|
function addRateLimitSnapshot(
|
|
snapshot: JsonObject,
|
|
snapshots: JsonObject[],
|
|
seen: Set<string>,
|
|
): void {
|
|
const signature = [
|
|
readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id") ?? "",
|
|
readNullableString(snapshot, "limitName") ?? readNullableString(snapshot, "limit_name") ?? "",
|
|
formatWindowSignature(snapshot.primary),
|
|
formatWindowSignature(snapshot.secondary),
|
|
].join("|");
|
|
if (seen.has(signature)) {
|
|
return;
|
|
}
|
|
seen.add(signature);
|
|
snapshots.push(snapshot);
|
|
}
|
|
|
|
function isRateLimitSnapshot(value: JsonObject): boolean {
|
|
return (
|
|
isJsonObject(value.primary) ||
|
|
isJsonObject(value.secondary) ||
|
|
value.rateLimitReachedType !== undefined ||
|
|
value.rate_limit_reached_type !== undefined ||
|
|
value.limitId !== undefined ||
|
|
value.limit_id !== undefined ||
|
|
value.limitName !== undefined ||
|
|
value.limit_name !== undefined
|
|
);
|
|
}
|
|
|
|
function readRateLimitWindow(
|
|
snapshot: JsonObject,
|
|
key: LimitWindowKey,
|
|
): RateLimitReset | undefined {
|
|
const window = snapshot[key];
|
|
if (!isJsonObject(window)) {
|
|
return undefined;
|
|
}
|
|
const resetsAt = readNumber(window, "resetsAt") ?? readNumber(window, "resets_at");
|
|
const resetsAtMs =
|
|
resolveExpiresAtMsFromEpochSeconds(resetsAt, { maxMs: MAX_DATE_TIMESTAMP_MS }) ?? 0;
|
|
return {
|
|
resetsAtMs,
|
|
...readOptionalNumberField(window, "usedPercent", "used_percent"),
|
|
...readOptionalNumberField(
|
|
window,
|
|
"windowDurationMins",
|
|
"window_duration_mins",
|
|
"windowMinutes",
|
|
"window_minutes",
|
|
),
|
|
};
|
|
}
|
|
|
|
function snapshotHasDisplayableData(snapshot: JsonObject): boolean {
|
|
if (
|
|
readString(snapshot, "rateLimitReachedType") ??
|
|
readString(snapshot, "rate_limit_reached_type")
|
|
) {
|
|
return true;
|
|
}
|
|
return readWindowEntries(snapshot).some(
|
|
(entry) => entry.window.usedPercent !== undefined || entry.window.resetsAtMs > 0,
|
|
);
|
|
}
|
|
|
|
function readOptionalNumberField(
|
|
record: JsonObject,
|
|
...keys: string[]
|
|
): { usedPercent?: number; windowDurationMins?: number } {
|
|
const value = keys.map((key) => readNumber(record, key)).find((entry) => entry !== undefined);
|
|
if (value === undefined) {
|
|
return {};
|
|
}
|
|
return keys.some((key) => key.toLowerCase().includes("window"))
|
|
? { windowDurationMins: value }
|
|
: { usedPercent: value };
|
|
}
|
|
|
|
function formatRateLimitWindow(key: LimitWindowKey, window: RateLimitReset, nowMs: number): string {
|
|
return `${key} ${formatRateLimitWindowDetails(window, nowMs)}`;
|
|
}
|
|
|
|
function formatRateLimitWindowDetails(window: RateLimitReset, nowMs: number): string {
|
|
const remainingPercent =
|
|
window.usedPercent === undefined
|
|
? "usage unknown"
|
|
: `${Math.max(0, 100 - Math.round(window.usedPercent))}% left`;
|
|
const reset =
|
|
window.resetsAtMs > nowMs ? ` ⏱${formatResetDuration(window.resetsAtMs, nowMs)}` : "";
|
|
return `${remainingPercent}${reset}`;
|
|
}
|
|
|
|
function formatLimitLabel(snapshot: JsonObject): string {
|
|
const label =
|
|
readNullableString(snapshot, "limitName") ??
|
|
readNullableString(snapshot, "limit_name") ??
|
|
readNullableString(snapshot, "limitId") ??
|
|
readNullableString(snapshot, "limit_id");
|
|
if (!label || label === CODEX_LIMIT_ID) {
|
|
return "Codex";
|
|
}
|
|
return label.replace(/[_-]+/gu, " ").replace(/\s+/gu, " ").trim();
|
|
}
|
|
|
|
function formatReachedType(value: string): string {
|
|
return value.replace(/[_-]+/gu, " ").replace(/\s+/gu, " ").trim();
|
|
}
|
|
|
|
function formatResetTime(resetsAtMs: number, nowMs: number): string {
|
|
return `in ${formatRelativeDuration(resetsAtMs - nowMs)}, ${formatCalendarResetTime(
|
|
resetsAtMs,
|
|
nowMs,
|
|
)}`;
|
|
}
|
|
|
|
function formatAccountResetTime(resetsAtMs: number, nowMs: number): string {
|
|
return `${formatCalendarResetTime(resetsAtMs, nowMs)} (in ${formatRelativeDuration(
|
|
resetsAtMs - nowMs,
|
|
)})`;
|
|
}
|
|
|
|
function snapshotHasLimitBlock(snapshot: JsonObject): boolean {
|
|
return Boolean(
|
|
readString(snapshot, "rateLimitReachedType") ??
|
|
readString(snapshot, "rate_limit_reached_type") ??
|
|
readWindowEntries(snapshot).some(
|
|
(entry) => entry.window.usedPercent !== undefined && entry.window.usedPercent >= 100,
|
|
),
|
|
);
|
|
}
|
|
|
|
function isCodexLimitSnapshot(snapshot: JsonObject): boolean {
|
|
const id = readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id");
|
|
return !id || id === CODEX_LIMIT_ID;
|
|
}
|
|
|
|
function selectCodexProviderUsageSnapshot(value: unknown): JsonObject | undefined {
|
|
const snapshots = collectCodexRateLimitSnapshots(value as JsonValue | undefined);
|
|
return snapshots.find(isCodexLimitSnapshot) ?? snapshots[0];
|
|
}
|
|
|
|
function readProviderUsageWindow(
|
|
entry: RateLimitWindowEntry,
|
|
entries: RateLimitWindowEntry[],
|
|
): UsageWindow | undefined {
|
|
const { window } = entry;
|
|
if (window.usedPercent === undefined && window.resetsAtMs <= 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
label: formatProviderUsageWindowLabel(entry, entries),
|
|
usedPercent: clampPercent(window.usedPercent ?? 0),
|
|
resetAt: window.resetsAtMs > 0 ? window.resetsAtMs : undefined,
|
|
};
|
|
}
|
|
|
|
function formatProviderUsageWindowLabel(
|
|
entry: RateLimitWindowEntry,
|
|
entries: RateLimitWindowEntry[],
|
|
): string {
|
|
const minutes = entry.window.windowDurationMins;
|
|
if (minutes === WEEKLY_WINDOW_MINUTES || hasWeeklySecondaryResetCadence(entry, entries)) {
|
|
return "Week";
|
|
}
|
|
if (minutes === DAY_WINDOW_MINUTES) {
|
|
return "Day";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes < DAY_WINDOW_MINUTES) {
|
|
return minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`;
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes % DAY_WINDOW_MINUTES === 0) {
|
|
return `${minutes / DAY_WINDOW_MINUTES}d`;
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes % 60 === 0) {
|
|
return `${minutes / 60}h`;
|
|
}
|
|
return entry.key === "primary" ? "Short" : "Long";
|
|
}
|
|
|
|
function resolveCodexProviderUsagePlan(snapshot: JsonObject): string | undefined {
|
|
const plan = readString(snapshot, "planType") ?? readString(snapshot, "plan_type");
|
|
const credits = isJsonObject(snapshot.credits) ? snapshot.credits : undefined;
|
|
const creditSummary = formatCodexCreditSummary(credits);
|
|
if (!creditSummary) {
|
|
return plan;
|
|
}
|
|
return plan ? `${plan} (${creditSummary})` : creditSummary;
|
|
}
|
|
|
|
function formatCodexCreditSummary(credits: JsonObject | undefined): string | undefined {
|
|
if (!credits) {
|
|
return undefined;
|
|
}
|
|
const hasCredits = readBoolean(credits, "hasCredits") ?? readBoolean(credits, "has_credits");
|
|
if (hasCredits === false) {
|
|
return undefined;
|
|
}
|
|
if (readBoolean(credits, "unlimited")) {
|
|
return "Unlimited credits";
|
|
}
|
|
const balance =
|
|
typeof credits.balance === "string"
|
|
? parseStrictFiniteNumber(credits.balance)
|
|
: asFiniteNumber(credits.balance);
|
|
if (balance === undefined || balance <= 0) {
|
|
return undefined;
|
|
}
|
|
const roundedBalance = Math.round(balance);
|
|
return roundedBalance > 0 ? `${roundedBalance} credits` : undefined;
|
|
}
|
|
|
|
function selectSnapshotBlockingReset(
|
|
snapshot: JsonObject,
|
|
nowMs: number,
|
|
): RateLimitReset | undefined {
|
|
const futureWindows = readWindowEntries(snapshot)
|
|
.map((entry) => entry.window)
|
|
.filter((window) => window.resetsAtMs > nowMs);
|
|
const exhaustedWindows = futureWindows.filter(
|
|
(window) => window.usedPercent !== undefined && window.usedPercent >= 100,
|
|
);
|
|
const candidates = exhaustedWindows.length > 0 ? exhaustedWindows : futureWindows;
|
|
const resetSort =
|
|
exhaustedWindows.length > 0
|
|
? (left: RateLimitReset, right: RateLimitReset) => right.resetsAtMs - left.resetsAtMs
|
|
: (left: RateLimitReset, right: RateLimitReset) => left.resetsAtMs - right.resetsAtMs;
|
|
return candidates.toSorted(resetSort)[0];
|
|
}
|
|
|
|
function selectBlockingWindowEntry(
|
|
entries: RateLimitWindowEntry[],
|
|
nowMs: number,
|
|
): RateLimitWindowEntry | undefined {
|
|
const futureEntries = entries.filter((entry) => entry.window.resetsAtMs > nowMs);
|
|
const exhaustedFutureEntries = futureEntries.filter(
|
|
(entry) => entry.window.usedPercent !== undefined && entry.window.usedPercent >= 100,
|
|
);
|
|
const resetCandidates =
|
|
exhaustedFutureEntries.length > 0 ? exhaustedFutureEntries : futureEntries;
|
|
if (resetCandidates.length > 0) {
|
|
const resetSort =
|
|
exhaustedFutureEntries.length > 0
|
|
? (left: RateLimitWindowEntry, right: RateLimitWindowEntry) =>
|
|
right.window.resetsAtMs - left.window.resetsAtMs
|
|
: (left: RateLimitWindowEntry, right: RateLimitWindowEntry) =>
|
|
left.window.resetsAtMs - right.window.resetsAtMs;
|
|
return resetCandidates.toSorted(resetSort)[0];
|
|
}
|
|
const exhaustedEntries = entries.filter(
|
|
(entry) => entry.window.usedPercent !== undefined && entry.window.usedPercent >= 100,
|
|
);
|
|
return exhaustedEntries.toSorted(
|
|
(left, right) => (right.window.windowDurationMins ?? 0) - (left.window.windowDurationMins ?? 0),
|
|
)[0];
|
|
}
|
|
|
|
function readWindowEntries(snapshot: JsonObject): RateLimitWindowEntry[] {
|
|
return LIMIT_WINDOW_KEYS.flatMap((key) => {
|
|
const window = readRateLimitWindow(snapshot, key);
|
|
return window ? [{ key, window }] : [];
|
|
});
|
|
}
|
|
|
|
function formatBlockingLimitPeriod(
|
|
entry: RateLimitWindowEntry | undefined,
|
|
entries: RateLimitWindowEntry[],
|
|
): string | undefined {
|
|
const minutes = entry?.window.windowDurationMins;
|
|
if (
|
|
entry &&
|
|
(minutes === WEEKLY_WINDOW_MINUTES || hasWeeklySecondaryResetCadence(entry, entries))
|
|
) {
|
|
return "weekly";
|
|
}
|
|
if (minutes === DAY_WINDOW_MINUTES) {
|
|
return "daily";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes < DAY_WINDOW_MINUTES) {
|
|
return "short-term";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatUsageLine(snapshot: JsonObject): string | undefined {
|
|
const entries = readWindowEntries(snapshot);
|
|
const windows = entries
|
|
.filter((entry) => entry.window.usedPercent !== undefined)
|
|
.toSorted(
|
|
(left, right) =>
|
|
(right.window.windowDurationMins ?? 0) - (left.window.windowDurationMins ?? 0),
|
|
)
|
|
.map((entry) => {
|
|
const label = formatUsageWindowLabel(entry, entries);
|
|
return `${label} ${Math.round(entry.window.usedPercent ?? 0)}%`;
|
|
});
|
|
return windows.length > 0 ? windows.join(" \u00b7 ") : undefined;
|
|
}
|
|
|
|
function formatUsageWindowLabel(
|
|
entry: RateLimitWindowEntry,
|
|
entries: RateLimitWindowEntry[],
|
|
): string {
|
|
const minutes = entry.window.windowDurationMins;
|
|
if (minutes === WEEKLY_WINDOW_MINUTES || hasWeeklySecondaryResetCadence(entry, entries)) {
|
|
return "weekly";
|
|
}
|
|
if (minutes === DAY_WINDOW_MINUTES) {
|
|
return "daily";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes < DAY_WINDOW_MINUTES) {
|
|
return "short-term";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes % DAY_WINDOW_MINUTES === 0) {
|
|
const days = minutes / DAY_WINDOW_MINUTES;
|
|
return `${days}-day`;
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes % 60 === 0) {
|
|
const hours = minutes / 60;
|
|
return `${hours}-hour`;
|
|
}
|
|
return "usage";
|
|
}
|
|
|
|
function hasWeeklySecondaryResetCadence(
|
|
entry: RateLimitWindowEntry,
|
|
entries: RateLimitWindowEntry[],
|
|
): boolean {
|
|
if (entry.key !== "secondary" || entry.window.windowDurationMins !== DAY_WINDOW_MINUTES) {
|
|
return false;
|
|
}
|
|
const primaryResetMs = entries.find((candidate) => candidate.key === "primary")?.window
|
|
.resetsAtMs;
|
|
return (
|
|
typeof primaryResetMs === "number" &&
|
|
primaryResetMs > 0 &&
|
|
entry.window.resetsAtMs > 0 &&
|
|
entry.window.resetsAtMs - primaryResetMs >= WEEKLY_RESET_GAP_MS
|
|
);
|
|
}
|
|
|
|
function formatCalendarResetTime(resetsAtMs: number, nowMs: number): string {
|
|
const resetDate = new Date(resetsAtMs);
|
|
const resetParts = new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
...(resetDate.getFullYear() === new Date(nowMs).getFullYear() ? {} : { year: "numeric" }),
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
timeZoneName: "short",
|
|
}).formatToParts(resetDate);
|
|
const part = (type: Intl.DateTimeFormatPartTypes): string | undefined =>
|
|
resetParts.find((entry) => entry.type === type)?.value;
|
|
const dateParts = [part("month"), part("day"), part("year")].filter(Boolean);
|
|
const day =
|
|
dateParts.length > 1 ? `${dateParts[0]} ${dateParts.slice(1).join(", ")}` : dateParts[0];
|
|
const time = [part("hour"), part("minute")].filter(Boolean).join(":");
|
|
const dayPeriod = part("dayPeriod");
|
|
const timeZone = part("timeZoneName");
|
|
return [day, "at", [time, dayPeriod, timeZone].filter(Boolean).join(" ")]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
function formatRelativeDuration(durationMs: number): string {
|
|
const safeMs = Math.max(1_000, durationMs);
|
|
if (safeMs < ONE_MINUTE_MS) {
|
|
return `${Math.ceil(safeMs / 1000)} seconds`;
|
|
}
|
|
if (safeMs < ONE_HOUR_MS) {
|
|
const minutes = Math.ceil(safeMs / ONE_MINUTE_MS);
|
|
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
}
|
|
if (safeMs < ONE_DAY_MS) {
|
|
const hours = Math.ceil(safeMs / ONE_HOUR_MS);
|
|
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
}
|
|
const days = Math.ceil(safeMs / ONE_DAY_MS);
|
|
return `${days} ${days === 1 ? "day" : "days"}`;
|
|
}
|
|
|
|
function formatResetDuration(resetsAtMs: number, nowMs: number): string {
|
|
const durationMs =
|
|
Math.round(Math.max(ONE_SECOND_MS, resetsAtMs - nowMs) / ONE_SECOND_MS) * ONE_SECOND_MS;
|
|
const days = Math.floor(durationMs / ONE_DAY_MS);
|
|
const hours = Math.floor((durationMs % ONE_DAY_MS) / ONE_HOUR_MS);
|
|
const minutes = Math.floor((durationMs % ONE_HOUR_MS) / ONE_MINUTE_MS);
|
|
const seconds = Math.floor((durationMs % ONE_MINUTE_MS) / ONE_SECOND_MS);
|
|
if (days > 0) {
|
|
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
}
|
|
if (hours > 0) {
|
|
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
}
|
|
if (minutes > 0) {
|
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
}
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
function formatWindowSignature(value: JsonValue | undefined): string {
|
|
if (!isJsonObject(value)) {
|
|
return "";
|
|
}
|
|
return `${readNumber(value, "usedPercent") ?? readNumber(value, "used_percent") ?? ""}:${
|
|
readNumber(value, "resetsAt") ?? readNumber(value, "resets_at") ?? ""
|
|
}`;
|
|
}
|
|
|
|
function extractCodexRetryHint(message: string | undefined): string | undefined {
|
|
if (!message) {
|
|
return undefined;
|
|
}
|
|
const tryAgainAt = /\btry again\s+(at\s+[^.!?\n]+)(?:[.!?]|$)/iu.exec(message);
|
|
if (tryAgainAt?.[1]) {
|
|
return tryAgainAt[1].trim();
|
|
}
|
|
const tryAgainRelative = /\btry again\s+((?:tomorrow|in\s+[^.!?\n]+)[^.!?\n]*)(?:[.!?]|$)/iu.exec(
|
|
message,
|
|
);
|
|
return tryAgainRelative?.[1]?.trim();
|
|
}
|
|
|
|
function readString(record: JsonObject, key: string): string | undefined {
|
|
const value = record[key];
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function readNullableString(record: JsonObject, key: string): string | undefined {
|
|
return readString(record, key) ?? undefined;
|
|
}
|
|
|
|
function readNumber(record: JsonObject, key: string): number | undefined {
|
|
return asFiniteNumber(record[key]);
|
|
}
|
|
|
|
function readBoolean(record: JsonObject, key: string): boolean | undefined {
|
|
const value = record[key];
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
function normalizeText(value: string | null | undefined): string | undefined {
|
|
const text = value?.trim();
|
|
return text ? text : undefined;
|
|
}
|