@@ -49,8 +49,8 @@ const GATEWAY_OVERLOAD_TEXT_MARKERS = [
4949/** User-visible line while the harness retries a transient gateway overload. */
5050export const GATEWAY_OVERLOAD_USER_MESSAGE = "Inference gateway overloaded — retrying…" ;
5151
52- /** User-visible line while the harness retries a short known-xAI HTTP 429. */
53- export const XAI_RATE_LIMIT_USER_MESSAGE = "Rate limited — retrying…" ;
52+ /** User-visible line while the harness retries a short known-provider HTTP 429. */
53+ export const RATE_LIMIT_USER_MESSAGE = "Rate limited — retrying…" ;
5454
5555/** Body markers that mean a real usage/quota window, not a short rate limit. */
5656const XAI_QUOTA_BODY_MARKERS = [
@@ -246,7 +246,67 @@ export function normalizeXaiRateLimitError(error: InferenceErrorWithGoContext):
246246
247247 return {
248248 category : "retryable" ,
249- message : XAI_RATE_LIMIT_USER_MESSAGE ,
249+ message : RATE_LIMIT_USER_MESSAGE ,
250+ statusCode : 429 ,
251+ ...( error . raw !== undefined ? { raw : error . raw } : { } ) ,
252+ ...( error . retryAfterMs !== undefined ? { retryAfterMs : error . retryAfterMs } : { } ) ,
253+ } ;
254+ }
255+
256+ function parseCodexUsageLimitFromError (
257+ error : InferenceErrorLike ,
258+ ) : ReturnType < typeof parseCodexUsageLimitError > {
259+ const candidates : unknown [ ] = [ ] ;
260+ if ( error . raw !== undefined ) candidates . push ( error . raw ) ;
261+ if ( typeof error . message === "string" && error . message . trim ( ) . startsWith ( "{" ) ) {
262+ candidates . push ( error . message ) ;
263+ }
264+
265+ for ( const candidate of candidates ) {
266+ const parsed = parseCodexUsageLimitError ( candidate ) ;
267+ if ( parsed !== undefined ) return parsed ;
268+ }
269+ return undefined ;
270+ }
271+
272+ function isKnownCodexProviderId ( providerId : string | undefined ) : boolean {
273+ return providerId !== undefined && isCodexProviderName ( providerId ) ;
274+ }
275+
276+ /**
277+ * True when a known-Codex HTTP 429 looks like a short rate limit rather than a
278+ * `usage_limit_reached` window. Used by both retry normalization and transcript
279+ * copy — FRIENDLY_BY_CATEGORY would otherwise paint every quota_exhausted 429 as
280+ * "Quota exhausted" even when the policy remaps it to retryable.
281+ *
282+ * Discrimination is the existing Codex usage-limit parser, not Retry-After length
283+ * and not ChatGPT usage-limit prose without `usage_limit_reached`.
284+ */
285+ export function isCodexShortRateLimitInferenceError ( error : InferenceErrorLike ) : boolean {
286+ if ( ! isKnownCodexProviderId ( error . providerId ) ) return false ;
287+ if ( error . statusCode !== 429 ) return false ;
288+ if ( error . category !== "quota_exhausted" && error . category !== "retryable" ) return false ;
289+ if ( parseCodexUsageLimitFromError ( error ) !== undefined ) return false ;
290+ return true ;
291+ }
292+
293+ /**
294+ * intx defaults bare 429 → quota_exhausted. For known-Codex contexts a bare 429
295+ * (or usage-limit prose without `usage_limit_reached`) reclassifies as retryable
296+ * so short ChatGPT 429s are not painted as a committed usage-limit window.
297+ *
298+ * Nested `detail.error.code === usage_limit_reached` stays quota_exhausted via
299+ * `normalizeCodexUsageLimitError`. Unknown / non-Codex providers are never remapped.
300+ */
301+ export function normalizeCodexRateLimitError ( error : InferenceErrorWithGoContext ) : InferenceError {
302+ if ( error . statusCode !== 429 ) return error ;
303+ if ( error . category !== "quota_exhausted" ) return error ;
304+ if ( ! isKnownCodexProviderId ( error . providerId ) ) return error ;
305+ if ( parseCodexUsageLimitFromError ( error ) !== undefined ) return error ;
306+
307+ return {
308+ category : "retryable" ,
309+ message : RATE_LIMIT_USER_MESSAGE ,
250310 statusCode : 429 ,
251311 ...( error . raw !== undefined ? { raw : error . raw } : { } ) ,
252312 ...( error . retryAfterMs !== undefined ? { retryAfterMs : error . retryAfterMs } : { } ) ,
@@ -266,17 +326,7 @@ function normalizeCodexUsageLimitError(error: InferenceErrorWithGoContext): Infe
266326 return error ;
267327 }
268328
269- const candidates : unknown [ ] = [ ] ;
270- if ( error . raw !== undefined ) candidates . push ( error . raw ) ;
271- if ( typeof error . message === "string" && error . message . trim ( ) . startsWith ( "{" ) ) {
272- candidates . push ( error . message ) ;
273- }
274-
275- let parsed = undefined as ReturnType < typeof parseCodexUsageLimitError > ;
276- for ( const candidate of candidates ) {
277- parsed = parseCodexUsageLimitError ( candidate ) ;
278- if ( parsed !== undefined ) break ;
279- }
329+ const parsed = parseCodexUsageLimitFromError ( error ) ;
280330 if ( parsed === undefined ) return error ;
281331
282332 const profile =
@@ -298,7 +348,8 @@ function normalizeCodexUsageLimitError(error: InferenceErrorWithGoContext): Infe
298348 * Reclassify gateway overload errors so the default retry policy treats them as
299349 * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode
300350 * Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short
301- * 429s, and Codex usage limits (nested detail.error with resets_in_seconds).
351+ * 429s, Codex usage limits (nested detail.error with resets_in_seconds), and
352+ * known-Codex short 429s that are not usage_limit_reached.
302353 */
303354export function normalizeInferenceErrorForRetry (
304355 error : InferenceErrorWithGoContext ,
@@ -312,6 +363,9 @@ export function normalizeInferenceErrorForRetry(
312363 const codexNormalized = normalizeCodexUsageLimitError ( error ) ;
313364 if ( codexNormalized !== error ) return codexNormalized ;
314365
366+ const codexRateLimit = normalizeCodexRateLimitError ( error ) ;
367+ if ( codexRateLimit !== error ) return codexRateLimit ;
368+
315369 if ( ! isGatewayOverloadInferenceError ( error ) ) return error ;
316370 if ( error . category === "retryable" || error . category === "timeout" ) return error ;
317371
0 commit comments