@@ -519,13 +519,61 @@ function normalizeCodexUsageLimitError(
519519 } ;
520520}
521521
522+ /**
523+ * Body/message markers that mean a Codex 404 names an unknown model rather
524+ * than rejecting the credential. The regex below covers the same phrasing
525+ * when a model name sits between "model" and "does not exist".
526+ */
527+ const CODEX_UNKNOWN_MODEL_MARKERS = [
528+ "model_not_found" ,
529+ "unknown model" ,
530+ ] as const ;
531+
532+ function hasCodexUnknownModelMarker ( error : InferenceErrorLike ) : boolean {
533+ const parts = [ error . message ?? "" , stringFromRaw ( error . raw ) ] ;
534+ if ( combinedTextIncludesMarker ( parts , CODEX_UNKNOWN_MODEL_MARKERS ) )
535+ return true ;
536+ return parts . some ( ( part ) =>
537+ / m o d e l \b [ ^ . ! ? ] { 0 , 60 } \b (?: d o e s n o t e x i s t | n o t f o u n d ) \b / i. test ( part ) ,
538+ ) ;
539+ }
540+
541+ /**
542+ * Codex answers unauthenticated requests with 426/404, so a fatal 404 in a
543+ * known-Codex context is an expired, invalid, or missing credential — not a
544+ * bad model name. The re-login copy matches the CodexAuthError shape so the
545+ * TUI names the affected profile through its existing auth matchers. Only an
546+ * explicit unknown-model marker keeps the fatal switch-models path.
547+ */
548+ function normalizeCodexCredential404Error (
549+ error : InferenceErrorWithGoContext ,
550+ ) : InferenceError {
551+ if ( error . category !== "fatal" ) return error ;
552+ if ( error . statusCode !== 404 ) return error ;
553+ const providerId = error . providerId ;
554+ if ( providerId === undefined || ! isCodexProviderName ( providerId ) )
555+ return error ;
556+ if ( hasCodexUnknownModelMarker ( error ) ) return error ;
557+ const profile = codexProfileFromProviderName ( providerId ) ?? providerId ;
558+ return {
559+ category : "credential_failure" ,
560+ message : `Codex profile "${ profile } " is not authorized. Log in again.` ,
561+ statusCode : 404 ,
562+ ...( error . raw !== undefined ? { raw : error . raw } : { } ) ,
563+ ...( error . retryAfterMs !== undefined
564+ ? { retryAfterMs : error . retryAfterMs }
565+ : { } ) ,
566+ } ;
567+ }
568+
522569/**
523570 * Reclassify gateway overload errors so the default retry policy treats them as
524571 * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode
525572 * Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short
526573 * 429s, attributable xAI capacity protocol_mismatch, Codex usage limits
527- * (nested detail.error with resets_in_seconds), and known-Codex short 429s that
528- * are not usage_limit_reached.
574+ * (nested detail.error with resets_in_seconds), known-Codex short 429s that
575+ * are not usage_limit_reached, and known-Codex credential 404s that do not
576+ * name an unknown model.
529577 */
530578export function normalizeInferenceErrorForRetry (
531579 error : InferenceErrorWithGoContext ,
@@ -545,6 +593,9 @@ export function normalizeInferenceErrorForRetry(
545593 const codexRateLimit = normalizeCodexRateLimitError ( error ) ;
546594 if ( codexRateLimit !== error ) return codexRateLimit ;
547595
596+ const codexCredential = normalizeCodexCredential404Error ( error ) ;
597+ if ( codexCredential !== error ) return codexCredential ;
598+
548599 if ( ! isGatewayOverloadInferenceError ( error ) ) return error ;
549600 if ( error . category === "retryable" || error . category === "timeout" )
550601 return error ;
0 commit comments