Files
openclaw/extensions/codex/src/app-server/usage-limit-error.ts
Peter Steinberger 1a27e7f3ec fix(codex): model-scoped usage-limit blocks, structural 429 classification, no silent API-key billing (#108254)
* test(codex): use allowlisted placeholder for auth-bridge api-key fixture

* fix(codex): model-scoped usage-limit blocks, structural 429 classification, no silent API-key billing

- usage-limit blocks written from Codex rate-limit resets are model-scoped via
  a persisted blockedScope marker: healthy sibling models on the same auth
  profile stay usable; a different/unknown model failing widens the block
  profile-wide and never narrows back; legacy rows without the marker stay
  profile-wide until they expire (#100556)
- Codex usage-limit failures now surface as status-429 Error objects at both
  ingress paths (turn-start and streamed turn failure, wrapped at the event
  projector), so core failover classification is structural instead of
  matching message wording; profile blocking uses only rate-limit data whose
  revision advanced during the turn
- usage-limit detection requires the structured codexErrorInfo signal; the
  over-broad "usage limit" substring match that misclassified unrelated
  errors as subscription limits is gone (#96815)
- subscription/OAuth routes can no longer silently fall back to env/auth.json
  API keys: ambient key fallback is restricted to explicit api-key routes,
  native-auth subscription routes verify the account is chatgpt-backed via
  account/read, and shared app-server clients are partitioned by auth
  requirement so pooled clients cannot cross billing modes (#106375)
- integrated e2e regression: usage-limit promptError -> same-model sibling
  profile rotation -> model fallback with reason rate_limit

* chore(codex): keep CodexUsageLimitErrorResult type local
2026-07-15 04:10:57 -07:00

264 lines
8.7 KiB
TypeScript

/**
* Enriches Codex usage-limit failures with current rate-limit information and
* marks blocked auth profiles when Codex exposes a reset time.
*/
import {
embeddedAgentLog,
formatErrorMessage,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { markAuthProfileBlockedUntil } from "openclaw/plugin-sdk/agent-runtime";
import { CODEX_CONTROL_METHODS } from "./capabilities.js";
import type { CodexAppServerClient } from "./client.js";
import {
isJsonObject,
type CodexServerNotification,
type JsonObject,
type JsonValue,
} from "./protocol.js";
import {
readCodexRateLimitsRevision,
readRecentCodexRateLimits,
rememberCodexRateLimitsRead,
} from "./rate-limit-cache.js";
import {
formatCodexUsageLimitErrorMessage,
resolveCodexUsageLimitResetAtMs,
shouldRefreshCodexRateLimitsForUsageLimitMessage,
} from "./rate-limits.js";
const CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS = 5_000;
type CodexUsageLimitErrorSource = {
message?: string | null;
codexErrorInfo?: JsonValue | null;
rateLimits?: JsonValue;
rateLimitsTrustedForProfile?: boolean;
};
type CodexUsageLimitErrorResult = {
message: string;
rateLimitsForProfile?: JsonValue;
};
export function createCodexUsageLimitPromptError(message: string): Error & { status: 429 } {
return Object.assign(new Error(message), { status: 429 as const });
}
export function isCodexUsageLimitPromptError(error: unknown): error is Error & { status: 429 } {
return error instanceof Error && "status" in error && error.status === 429;
}
/** Marks a Codex auth profile blocked until the reset time advertised by rate limits. */
export async function markCodexAuthProfileBlockedFromRateLimits(params: {
params: EmbeddedRunAttemptParams;
authProfileId?: string;
rateLimits?: JsonValue;
}): Promise<void> {
const authProfileId = params.authProfileId?.trim();
if (!authProfileId || !params.params.authProfileStore) {
return;
}
const blockedUntil = resolveCodexUsageLimitResetAtMs(params.rateLimits);
if (!blockedUntil) {
return;
}
try {
await markAuthProfileBlockedUntil({
store: params.params.authProfileStore,
profileId: authProfileId,
blockedUntil,
source: "codex_rate_limits",
agentDir: params.params.agentDir,
runId: params.params.runId,
modelId: params.params.modelId,
});
} catch (error) {
embeddedAgentLog.debug("failed to mark Codex auth profile blocked from app-server limits", {
authProfileId,
error: formatErrorMessage(error),
});
}
}
/** Formats a turn-start usage-limit error, refreshing rate limits when needed. */
export async function formatCodexTurnStartUsageLimitError(params: {
client: CodexAppServerClient;
error: unknown;
errorNotification?: CodexServerNotification;
rateLimitsRevisionBeforeTurnStart?: number;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CodexUsageLimitErrorResult | undefined> {
return refreshCodexUsageLimitError({
client: params.client,
source: readCodexTurnStartUsageLimitErrorSource(
params.client,
params.error,
params.errorNotification,
params.rateLimitsRevisionBeforeTurnStart,
),
timeoutMs: params.timeoutMs,
signal: params.signal,
});
}
/** Refreshes a generic prompt usage-limit message into a reset-aware message. */
export async function refreshCodexUsageLimitPromptError(params: {
client: CodexAppServerClient;
message: string | undefined;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CodexUsageLimitErrorResult | undefined> {
if (!shouldRefreshCodexRateLimitsForUsageLimitMessage(params.message)) {
return undefined;
}
return refreshCodexUsageLimitError({
client: params.client,
source: {
message: params.message,
codexErrorInfo: "usageLimitExceeded",
rateLimits: readRecentCodexRateLimits(params.client),
},
timeoutMs: params.timeoutMs,
signal: params.signal,
});
}
async function refreshCodexUsageLimitError(params: {
client: CodexAppServerClient;
source: CodexUsageLimitErrorSource;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CodexUsageLimitErrorResult | undefined> {
const initialMessage = formatCodexUsageLimitErrorMessage(params.source);
if (!shouldRefreshCodexRateLimitsForUsageLimitMessage(initialMessage)) {
return initialMessage
? {
message: initialMessage,
...(params.source.rateLimitsTrustedForProfile
? { rateLimitsForProfile: params.source.rateLimits }
: {}),
}
: undefined;
}
const rateLimits = await readCodexRateLimitsFromAppServerForUsageLimitError({
client: params.client,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!rateLimits) {
return initialMessage
? {
message: initialMessage,
...(params.source.rateLimitsTrustedForProfile
? { rateLimitsForProfile: params.source.rateLimits }
: {}),
}
: undefined;
}
const refreshedMessage = formatCodexUsageLimitErrorMessage({
message: params.source.message,
codexErrorInfo: params.source.codexErrorInfo,
rateLimits,
});
const message = refreshedMessage ?? initialMessage;
return message ? { message, rateLimitsForProfile: rateLimits } : undefined;
}
async function readCodexRateLimitsFromAppServerForUsageLimitError(params: {
client: CodexAppServerClient;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
if (params.signal?.aborted) {
return undefined;
}
try {
const rateLimits = await params.client.request(CODEX_CONTROL_METHODS.rateLimits, undefined, {
timeoutMs: resolveCodexUsageLimitRateLimitRefreshTimeoutMs(params.timeoutMs),
signal: params.signal,
});
rememberCodexRateLimitsRead(params.client, rateLimits);
return rateLimits;
} catch (error) {
embeddedAgentLog.debug("codex app-server rate-limit refresh failed after usage-limit error", {
error: formatErrorMessage(error),
});
return undefined;
}
}
function resolveCodexUsageLimitRateLimitRefreshTimeoutMs(timeoutMs: number | undefined): number {
if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS;
}
return Math.max(100, Math.min(timeoutMs, CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS));
}
function readCodexTurnStartUsageLimitErrorSource(
client: CodexAppServerClient,
error: unknown,
errorNotification: CodexServerNotification | undefined,
rateLimitsRevisionBeforeTurnStart: number | undefined,
): CodexUsageLimitErrorSource {
const notificationError = readCodexErrorNotification(errorNotification);
const errorPayload = readCodexErrorPayload(error);
const rateLimits = errorPayload.rateLimits ?? readRecentCodexRateLimits(client);
// The cached snapshot may predate this attempt or belong to another turn on
// the same process. Trust it for the auth profile only when this client saw
// a primary update after this turn's startup began.
const cacheUpdatedDuringTurnStart =
rateLimitsRevisionBeforeTurnStart !== undefined &&
readCodexRateLimitsRevision(client) > rateLimitsRevisionBeforeTurnStart;
return {
message: notificationError?.message ?? errorPayload.message ?? formatErrorMessage(error),
codexErrorInfo: notificationError?.codexErrorInfo ?? errorPayload.codexErrorInfo,
rateLimits,
rateLimitsTrustedForProfile:
errorPayload.rateLimits !== undefined || cacheUpdatedDuringTurnStart,
};
}
function readCodexErrorNotification(
notification: CodexServerNotification | undefined,
): { message?: string; codexErrorInfo?: JsonValue | null } | undefined {
if (notification?.method !== "error" || !isJsonObject(notification.params)) {
return undefined;
}
const error = notification.params.error;
return isJsonObject(error)
? {
message: readString(error, "message"),
codexErrorInfo: error.codexErrorInfo,
}
: 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;
const rateLimits = nestedError.rateLimits ?? data.rateLimits;
return {
message: readString(nestedError, "message") ?? message,
codexErrorInfo: nestedError.codexErrorInfo,
rateLimits,
};
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}