mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:40:44 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
48
extensions/codex/src/app-server/rate-limit-cache.ts
Normal file
48
extensions/codex/src/app-server/rate-limit-cache.ts
Normal 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;
|
||||
}
|
||||
262
extensions/codex/src/app-server/rate-limits.ts
Normal file
262
extensions/codex/src/app-server/rate-limits.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}` };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user