From 2ffe329d28078661093d97e4e464cad4fcfe63cf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 17:38:38 -0700 Subject: [PATCH 1/4] test(inference): prove Codex 404 masks expired credentials as fatal --- src/inference-error-message.test.ts | 21 +++++++++++++++ src/inference-gateway-error.test.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/inference-error-message.test.ts b/src/inference-error-message.test.ts index 19779728d..a1a8ce874 100644 --- a/src/inference-error-message.test.ts +++ b/src/inference-error-message.test.ts @@ -160,6 +160,27 @@ describe("terminalProviderFailureMessage", () => { ); }); + test("terminal Codex credential 404 names the profile with a re-login hint", () => { + const normalized = normalizeInferenceErrorForTerminal( + { category: "fatal", message: "Not Found", statusCode: 404 }, + "codex/work", + ); + const message = terminalProviderFailureMessage("codex/work", normalized); + expect(message).toContain('Codex profile "work"'); + expect(message.toLowerCase()).toMatch(/log in again/); + expect(message).not.toContain("/model"); + }); + + test("terminal genuine unknown-model 404 keeps switch-models guidance", () => { + const message = terminalProviderFailureMessage("codex/work", { + category: "fatal", + message: "The model 'gpt-99' does not exist", + statusCode: 404, + providerId: "codex/work", + }); + expect(message).toContain('"/model"'); + }); + test.each([ { name: "Bearer header", diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 622795f2c..8c9dfe9f6 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -279,6 +279,46 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalized).toBe(error); }); + test("Codex 404 without a model marker reclassifies as credential_failure", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + }); + expect(normalized.category).toBe("credential_failure"); + expect(normalized.message).toBe( + 'Codex profile "work" is not authorized. Log in again.', + ); + }); + + test("Codex 404 naming an unknown model keeps fatal switch-models guidance", () => { + const error = { + category: "fatal" as const, + message: "The model 'gpt-99' does not exist", + statusCode: 404, + providerId: "codex/work", + raw: { + error: { + code: "model_not_found", + message: "The model 'gpt-99' does not exist", + type: "invalid_request_error", + }, + }, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("non-Codex 404 keeps fatal switch-models guidance", () => { + const error = { + category: "fatal" as const, + message: "Not Found", + statusCode: 404, + providerId: "custom-provider", + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + test("known-xAI message-only capacity protocol error becomes retryable", () => { const normalized = normalizeInferenceErrorForRetry({ category: "protocol_mismatch", From 63c601aec3788d9fce2eca21dbf07c193e96b308 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 17:41:50 -0700 Subject: [PATCH 2/4] fix(inference): treat Codex 404s as expired credentials --- src/inference-error-message.ts | 15 ++++++++-- src/inference-gateway-error.ts | 55 ++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index cf1d8ef4e..9b589200d 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -146,14 +146,21 @@ export function terminalProviderFailureMessage( ? diagnostic : `${diagnostic}.`; const guidance = terminalProviderFailureGuidance(error, category); - return `${label} Provider failed (${category}): ${diagnosticSentence} ${guidance}`; + const tail = guidance.length > 0 ? ` ${guidance}` : ""; + return `${label} Provider failed (${category}): ${diagnosticSentence}${tail}`; } function terminalProviderFailureGuidance( error: InferenceErrorLike, category: string, ): string { - if (category === "credential_failure") return CREDENTIAL_FAILURE_USER_MESSAGE; + if (category === "credential_failure") { + // Normalized credential failures already carry the re-login hint in the + // diagnostic (e.g. Codex profile copy); repeating it reads as a stutter. + return /log in again|sign in again/i.test(error.message ?? "") + ? "" + : CREDENTIAL_FAILURE_USER_MESSAGE; + } if (category === "context_overflow") return "Try /clear to start fresh."; // A 429 that survived the harness's paced retries is a wait-it-out rate // limit, not a generic flake: say so instead of the bare "Try again." @@ -180,7 +187,9 @@ function terminalProviderFailureSummary( ): string { const label = terminalProviderFailureLabel(providerId, displayLabel); const category = terminalProviderFailureCategory(error); - return `${label} Provider failed (${category}). ${terminalProviderFailureGuidance(error, category)}`; + const guidance = terminalProviderFailureGuidance(error, category); + const tail = guidance.length > 0 ? ` ${guidance}` : ""; + return `${label} Provider failed (${category}).${tail}`; } export type ResolvedProviderFailureError = Error & { diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 2e97ebbd6..5475a276e 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -519,13 +519,61 @@ function normalizeCodexUsageLimitError( }; } +/** + * Body/message markers that mean a Codex 404 names an unknown model rather + * than rejecting the credential. The regex below covers the same phrasing + * when a model name sits between "model" and "does not exist". + */ +const CODEX_UNKNOWN_MODEL_MARKERS = [ + "model_not_found", + "unknown model", +] as const; + +function hasCodexUnknownModelMarker(error: InferenceErrorLike): boolean { + const parts = [error.message ?? "", stringFromRaw(error.raw)]; + if (combinedTextIncludesMarker(parts, CODEX_UNKNOWN_MODEL_MARKERS)) + return true; + return parts.some((part) => + /model\b[^.!?]{0,60}\b(?:does not exist|not found)\b/i.test(part), + ); +} + +/** + * Codex answers unauthenticated requests with 426/404, so a fatal 404 in a + * known-Codex context is an expired, invalid, or missing credential — not a + * bad model name. The re-login copy matches the CodexAuthError shape so the + * TUI names the affected profile through its existing auth matchers. Only an + * explicit unknown-model marker keeps the fatal switch-models path. + */ +function normalizeCodexCredential404Error( + error: InferenceErrorWithGoContext, +): InferenceError { + if (error.category !== "fatal") return error; + if (error.statusCode !== 404) return error; + const providerId = error.providerId; + if (providerId === undefined || !isCodexProviderName(providerId)) + return error; + if (hasCodexUnknownModelMarker(error)) return error; + const profile = codexProfileFromProviderName(providerId) ?? providerId; + return { + category: "credential_failure", + message: `Codex profile "${profile}" is not authorized. Log in again.`, + statusCode: 404, + ...(error.raw !== undefined ? { raw: error.raw } : {}), + ...(error.retryAfterMs !== undefined + ? { retryAfterMs: error.retryAfterMs } + : {}), + }; +} + /** * Reclassify gateway overload errors so the default retry policy treats them as * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode * Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short * 429s, attributable xAI capacity protocol_mismatch, Codex usage limits - * (nested detail.error with resets_in_seconds), and known-Codex short 429s that - * are not usage_limit_reached. + * (nested detail.error with resets_in_seconds), known-Codex short 429s that + * are not usage_limit_reached, and known-Codex credential 404s that do not + * name an unknown model. */ export function normalizeInferenceErrorForRetry( error: InferenceErrorWithGoContext, @@ -545,6 +593,9 @@ export function normalizeInferenceErrorForRetry( const codexRateLimit = normalizeCodexRateLimitError(error); if (codexRateLimit !== error) return codexRateLimit; + const codexCredential = normalizeCodexCredential404Error(error); + if (codexCredential !== error) return codexCredential; + if (!isGatewayOverloadInferenceError(error)) return error; if (error.category === "retryable" || error.category === "timeout") return error; From 132b0bc70a901baa45e25316d5715c2525e909c4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 20:15:19 -0700 Subject: [PATCH 3/4] fix(inference): gate Codex 404 reclassification on auth markers --- src/inference-error-message.test.ts | 42 +++++++++- src/inference-error-message.ts | 4 +- src/inference-gateway-error.test.ts | 122 +++++++++++++++++++++++++++- src/inference-gateway-error.ts | 106 +++++++++++++++++++----- 4 files changed, 247 insertions(+), 27 deletions(-) diff --git a/src/inference-error-message.test.ts b/src/inference-error-message.test.ts index a1a8ce874..a4c56a237 100644 --- a/src/inference-error-message.test.ts +++ b/src/inference-error-message.test.ts @@ -162,13 +162,51 @@ describe("terminalProviderFailureMessage", () => { test("terminal Codex credential 404 names the profile with a re-login hint", () => { const normalized = normalizeInferenceErrorForTerminal( - { category: "fatal", message: "Not Found", statusCode: 404 }, + { + category: "fatal", + message: "Not Found", + statusCode: 404, + raw: { + error: { + code: "invalid_token", + message: "Not authorized: the access token has been revoked", + }, + }, + }, "codex/work", ); const message = terminalProviderFailureMessage("codex/work", normalized); expect(message).toContain('Codex profile "work"'); - expect(message.toLowerCase()).toMatch(/log in again/); + expect(message).toContain("Not Found"); expect(message).not.toContain("/model"); + // One re-login hint, not a stutter: the branded diagnostic dedups via + // the shared carriesCodexReLoginHint predicate. + expect(message.toLowerCase().match(/log in again/g)).toHaveLength(1); + }); + + test("terminal bare Codex 404 without an auth signal keeps switch-models guidance", () => { + const normalized = normalizeInferenceErrorForTerminal( + { category: "fatal", message: "Not Found", statusCode: 404 }, + "codex/work", + ); + const message = terminalProviderFailureMessage("codex/work", normalized); + expect(message).toContain('"/model"'); + expect(message.toLowerCase()).not.toMatch(/log in again/); + }); + + test("terminal Codex refresh-failed auth error renders one re-login hint", () => { + // Joint surface with #1139 (CL-8628 refresh serialization): both lanes + // land credential failures in the CodexAuthError copy shape, and the + // terminal dedup must swallow the generic hint for either source. + const message = terminalProviderFailureMessage("codex/work", { + category: "credential_failure", + message: + 'Codex profile "work" could not be refreshed (invalid_grant). Log in again.', + statusCode: 401, + providerId: "codex/work", + }); + expect(message).toContain('Codex profile "work"'); + expect(message.toLowerCase().match(/log in again/g)).toHaveLength(1); }); test("terminal genuine unknown-model 404 keeps switch-models guidance", () => { diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index 9b589200d..0cd6126cd 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -14,6 +14,7 @@ import { import { stripTerminalControlSequences } from "./util/control-char-strip.js"; import { scrubSecretShapedContent } from "./plugins/tool-result-secret-scrub.js"; import { + carriesCodexReLoginHint, gatewayOverloadUserMessage, isCodexShortRateLimitInferenceError, isGatewayOverloadInferenceError, @@ -157,7 +158,8 @@ function terminalProviderFailureGuidance( if (category === "credential_failure") { // Normalized credential failures already carry the re-login hint in the // diagnostic (e.g. Codex profile copy); repeating it reads as a stutter. - return /log in again|sign in again/i.test(error.message ?? "") + // Shared with the classifier via carriesCodexReLoginHint — one predicate. + return carriesCodexReLoginHint(error.message ?? "") ? "" : CREDENTIAL_FAILURE_USER_MESSAGE; } diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 8c9dfe9f6..93af430e8 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { + carriesCodexReLoginHint, + codexCredential404ReclassifiedStats, GATEWAY_OVERLOAD_USER_MESSAGE, isGatewayOverloadInferenceError, looksLikeHtmlGatewayBody, normalizeInferenceErrorForRetry, + resetCodexCredential404StatsForTests, XAI_CAPACITY_USER_MESSAGE, } from "./inference-gateway-error.js"; @@ -279,17 +282,95 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalized).toBe(error); }); - test("Codex 404 without a model marker reclassifies as credential_failure", () => { + /** + * Wire shape for a revoked Codex credential: the harness classifies the + * HTTP 404 as fatal with the statusText message while the JSON body rides + * on raw. The body carries the auth-rejection signal; the status line + * alone ("Not Found") must never reclassify. + */ + const REVOKED_CREDENTIAL_404_RAW = { + error: { + code: "invalid_token", + message: "Not authorized: the access token has been revoked", + type: "invalid_request_error", + }, + }; + + test("Codex 404 with a revoked-credential body reclassifies as credential_failure", () => { const normalized = normalizeInferenceErrorForRetry({ category: "fatal", message: "Not Found", statusCode: 404, providerId: "codex/work", + raw: REVOKED_CREDENTIAL_404_RAW, }); expect(normalized.category).toBe("credential_failure"); - expect(normalized.message).toBe( - 'Codex profile "work" is not authorized. Log in again.', - ); + expect(normalized.message).toContain('Codex profile "work"'); + expect(carriesCodexReLoginHint(normalized.message)).toBe(true); + // Original diagnostic rides along so the failure stays debuggable, and + // the wire body stays on raw for logs. + expect(normalized.message).toContain("Not Found"); + expect(normalized.raw).toEqual(REVOKED_CREDENTIAL_404_RAW); + }); + + test.each([ + { name: "not authorized", body: "Not authorized" }, + { name: "unauthorized", body: "401 Unauthorized" }, + { name: "invalid token", body: "Invalid token" }, + { name: "expired", body: "The access token expired" }, + { name: "revoked", body: "Token has been revoked" }, + ])("Codex 404 with $name signal reclassifies", ({ body }) => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + raw: { error: { message: body } }, + }); + expect(normalized.category).toBe("credential_failure"); + expect(carriesCodexReLoginHint(normalized.message)).toBe(true); + }); + + test("Codex 404 with an auth signal in the message reclassifies", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Invalid token: expired", + statusCode: 404, + providerId: "codex/work", + }); + expect(normalized.category).toBe("credential_failure"); + expect(normalized.message).toContain("Invalid token: expired"); + }); + + test("Codex bare 404 without an auth signal stays fatal", () => { + const error = { + category: "fatal" as const, + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("Codex routing 404 without an auth signal stays fatal", () => { + const error = { + category: "fatal" as const, + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + raw: { error: { code: "not_found", message: "No such endpoint" } }, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("Codex 404 naming a dotted unknown model stays fatal", () => { + const error = { + category: "fatal" as const, + message: "The model 'gpt-3.5-turbo' does not exist", + statusCode: 404, + providerId: "codex/work", + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); }); test("Codex 404 naming an unknown model keeps fatal switch-models guidance", () => { @@ -319,6 +400,39 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalizeInferenceErrorForRetry(error)).toBe(error); }); + test("non-Codex 404 with an auth signal keeps provider scoping", () => { + const error = { + category: "fatal" as const, + message: "Not Found", + statusCode: 404, + providerId: "custom-provider", + raw: { error: { message: "Token has been revoked" } }, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("reclassified Codex 404s bump the counter with a body sample", () => { + resetCodexCredential404StatsForTests(); + expect(codexCredential404ReclassifiedStats().count).toBe(0); + normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + raw: REVOKED_CREDENTIAL_404_RAW, + }); + // A fatal 404 without an auth signal must not bump the counter. + normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + }); + const stats = codexCredential404ReclassifiedStats(); + expect(stats.count).toBe(1); + expect(stats.lastSample).toContain("revoked"); + }); + test("known-xAI message-only capacity protocol error becomes retryable", () => { const normalized = normalizeInferenceErrorForRetry({ category: "protocol_mismatch", diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 5475a276e..147cfbe16 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -520,30 +520,94 @@ function normalizeCodexUsageLimitError( } /** - * Body/message markers that mean a Codex 404 names an unknown model rather - * than rejecting the credential. The regex below covers the same phrasing - * when a model name sits between "model" and "does not exist". + * Positive auth-rejection signals for the Codex credential-404 classifier. A + * known-Codex fatal 404 reclassifies to credential_failure ONLY when the + * message or raw body carries one of these markers — bare / routing / config + * 404s and genuine unknown-model rejections stay fatal with switch-models + * guidance. Negative unknown-model matching is deliberately not used here: + * every new backend phrasing would otherwise need an allowlist entry. */ -const CODEX_UNKNOWN_MODEL_MARKERS = [ - "model_not_found", - "unknown model", +const CODEX_CREDENTIAL_404_MARKERS = [ + "not authorized", + "unauthorized", + "unauthorised", + "invalid token", + "invalid_token", + "expired", + "revoked", ] as const; -function hasCodexUnknownModelMarker(error: InferenceErrorLike): boolean { - const parts = [error.message ?? "", stringFromRaw(error.raw)]; - if (combinedTextIncludesMarker(parts, CODEX_UNKNOWN_MODEL_MARKERS)) - return true; - return parts.some((part) => - /model\b[^.!?]{0,60}\b(?:does not exist|not found)\b/i.test(part), +function hasCodexCredentialAuthSignal(error: InferenceErrorLike): boolean { + return combinedTextIncludesMarker( + [error.message ?? "", stringFromRaw(error.raw)], + CODEX_CREDENTIAL_404_MARKERS, ); } +/** + * Single shared predicate behind the Codex credential-404 re-login copy: the + * classifier brands with it (formatCodexCredential404Message) and the + * terminal-guidance dedup checks with it, so the two cannot drift. + */ +export function carriesCodexReLoginHint(text: string): boolean { + return /log in again|sign in again/i.test(text); +} + +/** Branded re-login line for a Codex credential 404, diagnostic appended. */ +function formatCodexCredential404Message( + profile: string, + originalDiagnostic: string, +): string { + const branded = `Codex profile "${profile}" is not authorized. Log in again.`; + const oneLine = originalDiagnostic.replace(/\s+/g, " ").trim(); + if (oneLine.length === 0 || branded.includes(oneLine)) return branded; + const clipped = oneLine.length > 200 ? `${oneLine.slice(0, 199)}…` : oneLine; + return `${branded} (${clipped})`; +} + +/** + * Reclassification telemetry for the Codex credential-404 classifier. The + * backend invents new 404 reasons over time; the counter plus the last-body + * sample let future unknown-404 waves be spotted without guessing. + */ +let codexCredential404ReclassifiedCount = 0; +let lastReclassifiedCodex404Sample = ""; + +export function codexCredential404ReclassifiedStats(): { + readonly count: number; + readonly lastSample: string; +} { + return { + count: codexCredential404ReclassifiedCount, + lastSample: lastReclassifiedCodex404Sample, + }; +} + +export function resetCodexCredential404StatsForTests(): void { + codexCredential404ReclassifiedCount = 0; + lastReclassifiedCodex404Sample = ""; +} + +function recordCodexCredential404Reclassification( + error: InferenceErrorLike, +): void { + codexCredential404ReclassifiedCount += 1; + const sample = [error.message ?? "", stringFromRaw(error.raw)] + .join("\n") + .replace(/\s+/g, " ") + .trim(); + lastReclassifiedCodex404Sample = + sample.length > 500 ? `${sample.slice(0, 499)}…` : sample; +} + /** * Codex answers unauthenticated requests with 426/404, so a fatal 404 in a - * known-Codex context is an expired, invalid, or missing credential — not a - * bad model name. The re-login copy matches the CodexAuthError shape so the - * TUI names the affected profile through its existing auth matchers. Only an - * explicit unknown-model marker keeps the fatal switch-models path. + * known-Codex context whose body carries an auth-rejection signal is an + * expired, invalid, or revoked credential — not a bad model name. The + * re-login copy matches the CodexAuthError shape so the TUI names the + * affected profile through its existing auth matchers; the original + * diagnostic rides along in parens so the model name stays debuggable. + * Anything without an auth signal keeps the fatal switch-models path. */ function normalizeCodexCredential404Error( error: InferenceErrorWithGoContext, @@ -553,11 +617,13 @@ function normalizeCodexCredential404Error( const providerId = error.providerId; if (providerId === undefined || !isCodexProviderName(providerId)) return error; - if (hasCodexUnknownModelMarker(error)) return error; + if (!hasCodexCredentialAuthSignal(error)) return error; const profile = codexProfileFromProviderName(providerId) ?? providerId; + const message = formatCodexCredential404Message(profile, error.message ?? ""); + recordCodexCredential404Reclassification(error); return { category: "credential_failure", - message: `Codex profile "${profile}" is not authorized. Log in again.`, + message, statusCode: 404, ...(error.raw !== undefined ? { raw: error.raw } : {}), ...(error.retryAfterMs !== undefined @@ -572,8 +638,8 @@ function normalizeCodexCredential404Error( * Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short * 429s, attributable xAI capacity protocol_mismatch, Codex usage limits * (nested detail.error with resets_in_seconds), known-Codex short 429s that - * are not usage_limit_reached, and known-Codex credential 404s that do not - * name an unknown model. + * are not usage_limit_reached, and known-Codex 404s carrying an + * auth-rejection signal (expired/revoked credential). */ export function normalizeInferenceErrorForRetry( error: InferenceErrorWithGoContext, From 258eaea6b160e8fca407cf147236853aa28287ae Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 19:52:51 -0700 Subject: [PATCH 4/4] fix(inference): keep retired-model 404s fatal when they say expired The 'expired' credential-404 marker misfires on model-deprecation 404s ("model 'gpt-4o' has expired - migrate ..."): reclassifying those as credential_failure would send the operator to log in again for a model that no longer exists. Add a deprecation veto (model mention plus retirement signal) that runs before the auth markers, with a keeper test. Drop the cross-lane refresh-failed terminal test: that input shape is owned by #1139 (CL-8628), which covers the same dedup in runner/refresh-lock tests. --- src/inference-error-message.test.ts | 15 ----------- src/inference-gateway-error.test.ts | 21 ++++++++++++++++ src/inference-gateway-error.ts | 39 ++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/inference-error-message.test.ts b/src/inference-error-message.test.ts index a4c56a237..81e5b2f32 100644 --- a/src/inference-error-message.test.ts +++ b/src/inference-error-message.test.ts @@ -194,21 +194,6 @@ describe("terminalProviderFailureMessage", () => { expect(message.toLowerCase()).not.toMatch(/log in again/); }); - test("terminal Codex refresh-failed auth error renders one re-login hint", () => { - // Joint surface with #1139 (CL-8628 refresh serialization): both lanes - // land credential failures in the CodexAuthError copy shape, and the - // terminal dedup must swallow the generic hint for either source. - const message = terminalProviderFailureMessage("codex/work", { - category: "credential_failure", - message: - 'Codex profile "work" could not be refreshed (invalid_grant). Log in again.', - statusCode: 401, - providerId: "codex/work", - }); - expect(message).toContain('Codex profile "work"'); - expect(message.toLowerCase().match(/log in again/g)).toHaveLength(1); - }); - test("terminal genuine unknown-model 404 keeps switch-models guidance", () => { const message = terminalProviderFailureMessage("codex/work", { category: "fatal", diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 93af430e8..0a6ac1225 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -390,6 +390,27 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalizeInferenceErrorForRetry(error)).toBe(error); }); + test("Codex model-deprecation 404 containing 'expired' stays fatal", () => { + // Keeper: a retired model names itself with the credential marker word, + // but logging in again cannot resurrect it — the fatal switch-models + // path must win over the expired-credential reclassification. + const error = { + category: "fatal" as const, + message: + "The model 'gpt-4o' has expired. Migrate to 'gpt-5' to continue.", + statusCode: 404, + providerId: "codex/work", + raw: { + error: { + code: "model_expired", + message: "The model 'gpt-4o' has expired (2025-02-01).", + type: "invalid_request_error", + }, + }, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + test("non-Codex 404 keeps fatal switch-models guidance", () => { const error = { category: "fatal" as const, diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 147cfbe16..12c652831 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -544,6 +544,38 @@ function hasCodexCredentialAuthSignal(error: InferenceErrorLike): boolean { ); } +/** + * Model-deprecation signals that veto the credential-404 classifier. A + * retired-model 404 can itself carry the word "expired" ("model 'gpt-4o' + * has expired — migrate to 'gpt-5'"), and reclassifying it as + * credential_failure would send the operator to log in again for a model + * that no longer exists. The veto needs a model mention plus a deprecation + * signal so bare credential texts ("the access token expired") still + * reclassify. + */ +const CODEX_MODEL_DEPRECATION_MARKERS = [ + "model_expired", + "model_deprecated", + "deprecated", + "deprecation", + "retired", + "sunset", + "no longer supported", + "no longer available", + "has expired", + "end of life", +] as const; + +function looksLikeCodexModelDeprecation(error: InferenceErrorLike): boolean { + const combined = [error.message ?? "", stringFromRaw(error.raw)] + .join("\n") + .toLowerCase(); + if (!combined.includes("model")) return false; + return CODEX_MODEL_DEPRECATION_MARKERS.some((marker) => + combined.includes(marker), + ); +} + /** * Single shared predicate behind the Codex credential-404 re-login copy: the * classifier brands with it (formatCodexCredential404Message) and the @@ -607,7 +639,8 @@ function recordCodexCredential404Reclassification( * re-login copy matches the CodexAuthError shape so the TUI names the * affected profile through its existing auth matchers; the original * diagnostic rides along in parens so the model name stays debuggable. - * Anything without an auth signal keeps the fatal switch-models path. + * Anything without an auth signal keeps the fatal switch-models path, as + * does a model-deprecation 404 even when it carries the word "expired". */ function normalizeCodexCredential404Error( error: InferenceErrorWithGoContext, @@ -617,6 +650,10 @@ function normalizeCodexCredential404Error( const providerId = error.providerId; if (providerId === undefined || !isCodexProviderName(providerId)) return error; + // A retired model is a fatal switch-models failure, never a credential + // failure: the veto runs before the auth markers so deprecation wins over + // a merely expired-sounding word. + if (looksLikeCodexModelDeprecation(error)) return error; if (!hasCodexCredentialAuthSignal(error)) return error; const profile = codexProfileFromProviderName(providerId) ?? providerId; const message = formatCodexCredential404Message(profile, error.message ?? "");