Surface Codex usage-limit reset details in chat replies (#77557)

* fix(codex): surface usage limit reset details

* fix(codex): satisfy extension lint

* fix: surface codex runtime failures in tool-only replies
This commit is contained in:
pashpashpash
2026-05-04 17:00:39 -07:00
committed by GitHub
parent 306a582294
commit b2c3202a15
26 changed files with 1187 additions and 103 deletions

View File

@@ -14,6 +14,7 @@ import {
CodexAppServerEventProjector,
type CodexAppServerToolTelemetry,
} from "./event-projector.js";
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
import { createCodexTestModel } from "./test-support.js";
const THREAD_ID = "thread-1";
@@ -86,6 +87,7 @@ beforeEach(() => {
afterEach(async () => {
resetAgentEventsForTest();
resetGlobalHookRunner();
resetCodexRateLimitCacheForTests();
vi.restoreAllMocks();
for (const tempDir of tempDirs) {
await fs.rm(tempDir, { recursive: true, force: true });
@@ -140,6 +142,23 @@ function appServerError(params: { message: string; willRetry: boolean }): Projec
});
}
function rateLimitsUpdated(resetsAt: number): ProjectorNotification {
return {
method: "account/rateLimits/updated",
params: {
rateLimits: {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
secondary: null,
credits: null,
planType: "plus",
rateLimitReachedType: "rate_limit_reached",
},
},
} as ProjectorNotification;
}
function turnCompleted(items: unknown[] = []): ProjectorNotification {
return {
method: "turn/completed",
@@ -280,6 +299,95 @@ describe("CodexAppServerEventProjector", () => {
expect(result.lastAssistant).toBeUndefined();
});
it("uses Codex rate-limit resets for usage-limit app-server errors", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
await projector.handleNotification(rateLimitsUpdated(resetsAt));
await projector.handleNotification(
forCurrentTurn("error", {
error: {
message: "You've reached your usage limit.",
codexErrorInfo: "usageLimitExceeded",
additionalDetails: null,
},
willRetry: false,
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
expect(result.promptError).toContain("Next reset in");
expect(result.promptError).toContain("Run /codex account");
expect(result.promptErrorSource).toBe("prompt");
});
it("uses Codex rate-limit resets for failed turns", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
await projector.handleNotification(rateLimitsUpdated(resetsAt));
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "failed",
error: {
message: "You've reached your usage limit.",
codexErrorInfo: "usageLimitExceeded",
additionalDetails: null,
},
items: [],
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
expect(result.promptError).toContain("Next reset in");
expect(result.promptErrorSource).toBe("prompt");
});
it("uses a recent Codex rate-limit snapshot when failed turns omit reset details", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
rememberCodexRateLimits({
rateLimits: {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
secondary: null,
credits: null,
planType: "plus",
rateLimitReachedType: "rate_limit_reached",
},
rateLimitsByLimitId: null,
});
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "failed",
error: {
message: "You've reached your usage limit.",
codexErrorInfo: "usageLimitExceeded",
additionalDetails: null,
},
items: [],
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
expect(result.promptError).toContain("Next reset in");
expect(result.promptErrorSource).toBe("prompt");
});
it("normalizes snake_case current token usage fields", async () => {
const projector = await createProjector();

View File

@@ -27,6 +27,8 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import { attachCodexMirrorIdentity } from "./transcript-mirror.js";
@@ -100,6 +102,7 @@ export class CodexAppServerEventProjector {
private tokenUsage: ReturnType<typeof normalizeUsage>;
private guardianReviewCount = 0;
private completedCompactionCount = 0;
private latestRateLimits: JsonValue | undefined;
constructor(
private readonly params: EmbeddedRunAttemptParams,
@@ -112,6 +115,11 @@ export class CodexAppServerEventProjector {
if (!params) {
return;
}
if (notification.method === "account/rateLimits/updated") {
this.latestRateLimits = params;
rememberCodexRateLimits(params);
return;
}
if (isHookNotificationMethod(notification.method)) {
if (!this.isHookNotificationForCurrentThread(params)) {
return;
@@ -167,7 +175,7 @@ export class CodexAppServerEventProjector {
if (readBooleanAlias(params, ["willRetry", "will_retry"]) === true) {
break;
}
this.promptError = readCodexErrorNotificationMessage(params) ?? "codex app-server error";
this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error";
this.promptErrorSource = "prompt";
break;
default:
@@ -523,7 +531,14 @@ export class CodexAppServerEventProjector {
this.aborted = true;
}
if (turn.status === "failed") {
this.promptError = turn.error?.message ?? "codex app-server turn failed";
this.promptError =
formatCodexUsageLimitErrorMessage({
message: turn.error?.message,
codexErrorInfo: turn.error?.codexErrorInfo as JsonValue | null | undefined,
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
}) ??
turn.error?.message ??
"codex app-server turn failed";
this.promptErrorSource = "prompt";
}
for (const item of turn.items ?? []) {
@@ -746,6 +761,17 @@ export class CodexAppServerEventProjector {
});
}
private formatCodexErrorMessage(params: JsonObject): string | undefined {
const error = isJsonObject(params.error) ? params.error : undefined;
return (
formatCodexUsageLimitErrorMessage({
message: error ? readString(error, "message") : undefined,
codexErrorInfo: error?.codexErrorInfo,
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
}) ?? readCodexErrorNotificationMessage(params)
);
}
private emitAgentEvent(
event: Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>>[0],
): void {

View File

@@ -0,0 +1,48 @@
import type { JsonValue } from "./protocol.js";
const DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS = 10 * 60_000;
const CODEX_RATE_LIMIT_CACHE_STATE = Symbol.for("openclaw.codexRateLimitCacheState");
type CodexRateLimitCacheState = {
value?: JsonValue;
updatedAtMs?: number;
};
function getCodexRateLimitCacheState(): CodexRateLimitCacheState {
const globalState = globalThis as typeof globalThis & {
[CODEX_RATE_LIMIT_CACHE_STATE]?: CodexRateLimitCacheState;
};
globalState[CODEX_RATE_LIMIT_CACHE_STATE] ??= {};
return globalState[CODEX_RATE_LIMIT_CACHE_STATE];
}
export function rememberCodexRateLimits(value: JsonValue | undefined, nowMs = Date.now()): void {
if (value === undefined) {
return;
}
const state = getCodexRateLimitCacheState();
state.value = value;
state.updatedAtMs = nowMs;
}
export function readRecentCodexRateLimits(options?: {
nowMs?: number;
maxAgeMs?: number;
}): JsonValue | undefined {
const state = getCodexRateLimitCacheState();
if (state.value === undefined || state.updatedAtMs === undefined) {
return undefined;
}
const nowMs = options?.nowMs ?? Date.now();
const maxAgeMs = options?.maxAgeMs ?? DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS;
if (maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs) {
return undefined;
}
return state.value;
}
export function resetCodexRateLimitCacheForTests(): void {
const state = getCodexRateLimitCacheState();
state.value = undefined;
state.updatedAtMs = undefined;
}

View File

@@ -0,0 +1,262 @@
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
const CODEX_LIMIT_ID = "codex";
const LIMIT_WINDOW_KEYS = ["primary", "secondary"] as const;
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;
};
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 nextReset = selectNextRateLimitReset(params.rateLimits, nowMs);
const parts = ["You've reached your Codex subscription usage limit."];
if (nextReset) {
parts.push(`Next reset ${formatResetTime(nextReset.resetsAtMs, nowMs)}.`);
} else {
parts.push("Codex did not return a reset time for this limit.");
}
parts.push("Run /codex account for current usage details.");
return parts.join(" ");
}
export function summarizeCodexRateLimits(
value: JsonValue | undefined,
nowMs = Date.now(),
): string | undefined {
const snapshots = collectCodexRateLimitSnapshots(value);
if (snapshots.length === 0) {
return undefined;
}
return snapshots
.slice(0, 4)
.map((snapshot) => summarizeRateLimitSnapshot(snapshot, nowMs))
.join("; ");
}
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;
candidates.sort((left, right) => left.resetsAtMs - right.resetsAtMs);
return candidates[0];
}
function summarizeRateLimitSnapshot(snapshot: JsonObject, nowMs: number): string {
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");
const suffix = reachedType ? ` (${formatReachedType(reachedType)})` : "";
return `${label}: ${windows.join(", ") || "available"}${suffix}`;
}
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);
}
}
collectRateLimitSnapshots(value.rateLimits, 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, "limitName") ?? "",
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.limitId !== undefined ||
value.limitName !== undefined
);
}
function readRateLimitWindow(
snapshot: JsonObject,
key: LimitWindowKey,
): RateLimitReset | undefined {
const window = snapshot[key];
if (!isJsonObject(window)) {
return undefined;
}
const resetsAt = readNumber(window, "resetsAt");
return {
...(typeof resetsAt === "number" && Number.isFinite(resetsAt) && resetsAt > 0
? { resetsAtMs: resetsAt * 1000 }
: { resetsAtMs: 0 }),
...readOptionalNumberField(window, "usedPercent"),
};
}
function readOptionalNumberField(record: JsonObject, key: string): { usedPercent?: number } {
const value = readNumber(record, key);
return value === undefined ? {} : { usedPercent: value };
}
function formatRateLimitWindow(key: LimitWindowKey, window: RateLimitReset, nowMs: number): string {
const usedPercent =
window.usedPercent === undefined ? "usage unknown" : `${Math.round(window.usedPercent)}%`;
const reset =
window.resetsAtMs > nowMs ? `, resets ${formatResetTime(window.resetsAtMs, nowMs)}` : "";
return `${key} ${usedPercent}${reset}`;
}
function formatLimitLabel(snapshot: JsonObject): string {
const label =
readNullableString(snapshot, "limitName") ?? readNullableString(snapshot, "limitId");
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)} (${new Date(resetsAtMs).toISOString()})`;
}
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 formatWindowSignature(value: JsonValue | undefined): string {
if (!isJsonObject(value)) {
return "";
}
return `${readNumber(value, "usedPercent") ?? ""}:${readNumber(value, "resetsAt") ?? ""}`;
}
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 {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeText(value: string | null | undefined): string | undefined {
const text = value?.trim();
return text ? text : undefined;
}

View File

@@ -24,6 +24,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
import * as elicitationBridge from "./elicitation-bridge.js";
import type { CodexServerNotification } from "./protocol.js";
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
@@ -126,6 +127,23 @@ function turnStartResult(turnId = "turn-1", status = "inProgress") {
};
}
function rateLimitsUpdated(resetsAt: number): CodexServerNotification {
return {
method: "account/rateLimits/updated",
params: {
rateLimits: {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
secondary: null,
credits: null,
planType: "plus",
rateLimitReachedType: "rate_limit_reached",
},
},
};
}
function assistantMessage(text: string, timestamp: number) {
return {
role: "assistant" as const,
@@ -351,6 +369,7 @@ describe("runCodexAppServerAttempt", () => {
afterEach(async () => {
__testing.resetCodexAppServerClientFactoryForTests();
resetCodexRateLimitCacheForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
resetAgentEventsForTest();
resetGlobalHookRunner();
@@ -1171,6 +1190,71 @@ describe("runCodexAppServerAttempt", () => {
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("preserves Codex usage-limit reset details when turn/start fails", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
let harness!: ReturnType<typeof createStartedThreadHarness>;
harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
await harness.notify(rateLimitsUpdated(resetsAt));
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
}
return undefined;
});
const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch(
(error: unknown) => error,
);
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"You've reached your Codex subscription usage limit.",
);
expect((error as Error).message).toContain("Next reset in");
});
it("uses a recent Codex rate-limit snapshot when turn/start omits reset details", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
rememberCodexRateLimits({
rateLimits: {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
secondary: null,
credits: null,
planType: "plus",
rateLimitReachedType: "rate_limit_reached",
},
rateLimitsByLimitId: null,
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
}
return undefined;
});
const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch(
(error: unknown) => error,
);
await harness.waitForMethod("turn/start");
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"You've reached your Codex subscription usage limit.",
);
expect((error as Error).message).toContain("Next reset in");
});
it("cleans up native hook relay state when the Codex turn aborts", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -84,6 +84,8 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
@@ -1034,16 +1036,18 @@ export async function runCodexAppServerAttempt(
),
);
} catch (error) {
const usageLimitError = formatCodexTurnStartUsageLimitError(error, pendingNotifications);
const turnStartErrorMessage = usageLimitError ?? formatErrorMessage(error);
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_start_failed", error: formatErrorMessage(error) },
data: { phase: "turn_start_failed", error: turnStartErrorMessage },
});
trajectoryRecorder?.recordEvent("session.ended", {
status: "error",
threadId: thread.threadId,
timedOut,
aborted: runAbortController.signal.aborted,
promptError: normalizeCodexTrajectoryError(error),
promptError: turnStartErrorMessage,
});
trajectoryEndRecorded = true;
runAgentHarnessLlmOutputHook({
@@ -1065,7 +1069,7 @@ export async function runCodexAppServerAttempt(
event: {
messages: turnStartFailureMessages,
success: false,
error: formatErrorMessage(error),
error: turnStartErrorMessage,
durationMs: Date.now() - attemptStartedAt,
},
ctx: hookContext,
@@ -1083,6 +1087,11 @@ export async function runCodexAppServerAttempt(
},
});
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
if (usageLimitError) {
throw new Error(usageLimitError, {
cause: error,
});
}
throw error;
}
turnId = turn.turn.id;
@@ -1644,6 +1653,76 @@ function readDynamicToolCallParams(
return readCodexDynamicToolCallParams(value);
}
function formatCodexTurnStartUsageLimitError(
error: unknown,
pendingNotifications: CodexServerNotification[],
): string | undefined {
const notificationError = readLatestCodexErrorNotification(pendingNotifications);
const errorPayload = readCodexErrorPayload(error);
return formatCodexUsageLimitErrorMessage({
message: notificationError?.message ?? errorPayload.message ?? formatErrorMessage(error),
codexErrorInfo: notificationError?.codexErrorInfo ?? errorPayload.codexErrorInfo,
rateLimits:
readLatestRateLimitNotificationPayload(pendingNotifications) ??
errorPayload.rateLimits ??
readRecentCodexRateLimits(),
});
}
function readLatestRateLimitNotificationPayload(
notifications: CodexServerNotification[],
): JsonValue | undefined {
for (let index = notifications.length - 1; index >= 0; index -= 1) {
const notification = notifications[index];
if (notification?.method === "account/rateLimits/updated") {
rememberCodexRateLimits(notification.params);
return notification.params;
}
}
return undefined;
}
function readLatestCodexErrorNotification(
notifications: CodexServerNotification[],
): { message?: string; codexErrorInfo?: JsonValue | null } | undefined {
for (let index = notifications.length - 1; index >= 0; index -= 1) {
const notification = notifications[index];
if (notification?.method !== "error" || !isJsonObject(notification.params)) {
continue;
}
const error = notification.params.error;
if (!isJsonObject(error)) {
continue;
}
return {
message: readString(error, "message"),
codexErrorInfo: error.codexErrorInfo,
};
}
return undefined;
}
function readCodexErrorPayload(error: unknown): {
message?: string;
codexErrorInfo?: JsonValue | null;
rateLimits?: JsonValue;
} {
const message = error instanceof Error ? error.message : undefined;
if (!error || typeof error !== "object" || !("data" in error)) {
return { message };
}
const data = (error as { data?: unknown }).data as JsonValue | undefined;
if (!isJsonObject(data)) {
return { message };
}
const nestedError = isJsonObject(data.error) ? data.error : data;
return {
message: readString(nestedError, "message") ?? message,
codexErrorInfo: nestedError.codexErrorInfo,
rateLimits: nestedError.rateLimits ?? data.rateLimits,
};
}
function isTurnNotification(
value: JsonValue | undefined,
threadId: string,

View File

@@ -1,6 +1,7 @@
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import type { CodexAppServerModelListResult } from "./app-server/models.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import { summarizeCodexRateLimits } from "./app-server/rate-limits.js";
import type { SafeValue } from "./command-rpc.js";
type CodexStatusProbes = {
@@ -37,7 +38,7 @@ export function formatCodexStatus(probes: CodexStatusProbes): string {
lines.push(
`Rate limits: ${
probes.limits.ok
? summarizeRateLimits(probes.limits.value)
? formatCodexRateLimitSummary(probes.limits.value)
: formatCodexDisplayText(probes.limits.error)
}`,
);
@@ -104,7 +105,9 @@ export function formatAccount(
): string {
return [
`Account: ${account.ok ? formatCodexAccountSummary(account.value) : formatCodexDisplayText(account.error)}`,
`Rate limits: ${limits.ok ? summarizeRateLimits(limits.value) : formatCodexDisplayText(limits.error)}`,
`Rate limits: ${
limits.ok ? formatCodexRateLimitSummary(limits.value) : formatCodexDisplayText(limits.error)
}`,
].join("\n");
}
@@ -276,6 +279,10 @@ function summarizeArrayLike(value: JsonValue | undefined): string {
return `${entries.length}`;
}
function formatCodexRateLimitSummary(value: JsonValue | undefined): string {
return formatCodexDisplayText(summarizeCodexRateLimits(value) ?? summarizeRateLimits(value));
}
function summarizeRateLimits(value: JsonValue | undefined): string {
const entries = extractArray(value);
if (entries.length > 0) {

View File

@@ -9,6 +9,7 @@ import {
import type { CodexComputerUseConfig } from "./app-server/config.js";
import { listAllCodexAppServerModels } from "./app-server/models.js";
import { isJsonObject, type JsonValue } from "./app-server/protocol.js";
import { rememberCodexRateLimits } from "./app-server/rate-limit-cache.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
@@ -321,6 +322,9 @@ export async function handleCodexSubcommand(
undefined,
),
]);
if (limits.ok) {
rememberCodexRateLimits(limits.value);
}
return { text: formatAccount(account, limits) };
}
return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` };

View File

@@ -6,6 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import type { CodexAppServerStartOptions } from "./app-server/config.js";
import {
readRecentCodexRateLimits,
resetCodexRateLimitCacheForTests,
} from "./app-server/rate-limit-cache.js";
import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js";
import {
resetCodexDiagnosticsFeedbackStateForTests,
@@ -101,6 +105,7 @@ describe("codex command", () => {
afterEach(async () => {
resetCodexDiagnosticsFeedbackStateForTests();
resetCodexRateLimitCacheForTests();
resetSharedCodexAppServerClientForTests();
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -422,10 +427,10 @@ describe("codex command", () => {
});
await expect(handleCodexCommand(createContext("status"), { deps })).resolves.toMatchObject({
text: expect.stringContaining("Rate limits: 1"),
text: expect.stringContaining("Rate limits: Codex: primary 42%"),
});
await expect(handleCodexCommand(createContext("account"), { deps })).resolves.toMatchObject({
text: expect.stringContaining("Rate limits: 1"),
text: expect.stringContaining("Rate limits: Codex: primary 42%"),
});
});
@@ -476,6 +481,7 @@ describe("codex command", () => {
});
it("formats generated account/read responses", async () => {
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const safeCodexControlRequest = vi
.fn()
.mockResolvedValueOnce({
@@ -485,14 +491,30 @@ describe("codex command", () => {
requiresOpenaiAuth: false,
},
})
.mockResolvedValueOnce({ ok: true, value: { data: [{ name: "primary" }] } });
.mockResolvedValueOnce({
ok: true,
value: {
rateLimits: {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 50, windowDurationMins: 300, resetsAt },
secondary: null,
credits: null,
planType: "plus",
rateLimitReachedType: null,
},
rateLimitsByLimitId: null,
},
});
await expect(
handleCodexCommand(createContext("account"), {
deps: createDeps({ safeCodexControlRequest }),
}),
).resolves.toEqual({
text: ["Account: codex@example.com", "Rate limits: 1"].join("\n"),
const result = await handleCodexCommand(createContext("account"), {
deps: createDeps({ safeCodexControlRequest }),
});
expect(result.text).toContain("Account: codex@example.com");
expect(result.text).toContain("Rate limits: Codex: primary 50%, resets in");
expect(readRecentCodexRateLimits()).toMatchObject({
rateLimits: { limitId: "codex" },
});
expect(safeCodexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.account, {
refreshToken: false,