mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
630 lines
21 KiB
TypeScript
630 lines
21 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 { asFiniteNumber } 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;
|
|
|
|
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 blockingWindow = blockingSnapshot
|
|
? selectSnapshotBlockingWindow(blockingSnapshot, nowMs)
|
|
: undefined;
|
|
const blockingReset =
|
|
blockingWindow && blockingWindow.resetsAtMs > nowMs ? blockingWindow : undefined;
|
|
const blockingPeriod = formatBlockingLimitPeriod(blockingWindow?.windowDurationMins);
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
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 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 selectSnapshotBlockingWindow(
|
|
snapshot: JsonObject,
|
|
nowMs: number,
|
|
): RateLimitReset | undefined {
|
|
const resetWindow = selectSnapshotBlockingReset(snapshot, nowMs);
|
|
if (resetWindow) {
|
|
return resetWindow;
|
|
}
|
|
const exhaustedWindows = readWindowEntries(snapshot)
|
|
.map((entry) => entry.window)
|
|
.filter((window) => window.usedPercent !== undefined && window.usedPercent >= 100);
|
|
return exhaustedWindows.toSorted(
|
|
(left, right) => (right.windowDurationMins ?? 0) - (left.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(minutes: number | undefined): string | undefined {
|
|
if (minutes === 7 * 24 * 60) {
|
|
return "weekly";
|
|
}
|
|
if (minutes === 24 * 60) {
|
|
return "daily";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes < 24 * 60) {
|
|
return "short-term";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatUsageLine(snapshot: JsonObject): string | undefined {
|
|
const windows = readWindowEntries(snapshot)
|
|
.filter((entry) => entry.window.usedPercent !== undefined)
|
|
.toSorted(
|
|
(left, right) =>
|
|
(right.window.windowDurationMins ?? 0) - (left.window.windowDurationMins ?? 0),
|
|
)
|
|
.map((entry) => {
|
|
const label = formatUsageWindowLabel(entry.window.windowDurationMins);
|
|
return `${label} ${Math.round(entry.window.usedPercent ?? 0)}%`;
|
|
});
|
|
return windows.length > 0 ? windows.join(" \u00b7 ") : undefined;
|
|
}
|
|
|
|
function formatUsageWindowLabel(minutes: number | undefined): string {
|
|
if (minutes === 7 * 24 * 60) {
|
|
return "weekly";
|
|
}
|
|
if (minutes === 24 * 60) {
|
|
return "daily";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes < 24 * 60) {
|
|
return "short-term";
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes % (24 * 60) === 0) {
|
|
const days = minutes / (24 * 60);
|
|
return `${days}-day`;
|
|
}
|
|
if (minutes !== undefined && minutes > 0 && minutes % 60 === 0) {
|
|
const hours = minutes / 60;
|
|
return `${hours}-hour`;
|
|
}
|
|
return "usage";
|
|
}
|
|
|
|
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 normalizeText(value: string | null | undefined): string | undefined {
|
|
const text = value?.trim();
|
|
return text ? text : undefined;
|
|
}
|