Skip to content

Commit 258eaea

Browse files
committed
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.
1 parent 132b0bc commit 258eaea

3 files changed

Lines changed: 59 additions & 16 deletions

File tree

‎src/inference-error-message.test.ts‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -194,21 +194,6 @@ describe("terminalProviderFailureMessage", () => {
194194
expect(message.toLowerCase()).not.toMatch(/log in again/);
195195
});
196196

197-
test("terminal Codex refresh-failed auth error renders one re-login hint", () => {
198-
// Joint surface with #1139 (CL-8628 refresh serialization): both lanes
199-
// land credential failures in the CodexAuthError copy shape, and the
200-
// terminal dedup must swallow the generic hint for either source.
201-
const message = terminalProviderFailureMessage("codex/work", {
202-
category: "credential_failure",
203-
message:
204-
'Codex profile "work" could not be refreshed (invalid_grant). Log in again.',
205-
statusCode: 401,
206-
providerId: "codex/work",
207-
});
208-
expect(message).toContain('Codex profile "work"');
209-
expect(message.toLowerCase().match(/log in again/g)).toHaveLength(1);
210-
});
211-
212197
test("terminal genuine unknown-model 404 keeps switch-models guidance", () => {
213198
const message = terminalProviderFailureMessage("codex/work", {
214199
category: "fatal",

‎src/inference-gateway-error.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,27 @@ describe("normalizeInferenceErrorForRetry", () => {
390390
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
391391
});
392392

393+
test("Codex model-deprecation 404 containing 'expired' stays fatal", () => {
394+
// Keeper: a retired model names itself with the credential marker word,
395+
// but logging in again cannot resurrect it — the fatal switch-models
396+
// path must win over the expired-credential reclassification.
397+
const error = {
398+
category: "fatal" as const,
399+
message:
400+
"The model 'gpt-4o' has expired. Migrate to 'gpt-5' to continue.",
401+
statusCode: 404,
402+
providerId: "codex/work",
403+
raw: {
404+
error: {
405+
code: "model_expired",
406+
message: "The model 'gpt-4o' has expired (2025-02-01).",
407+
type: "invalid_request_error",
408+
},
409+
},
410+
};
411+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
412+
});
413+
393414
test("non-Codex 404 keeps fatal switch-models guidance", () => {
394415
const error = {
395416
category: "fatal" as const,

‎src/inference-gateway-error.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,38 @@ function hasCodexCredentialAuthSignal(error: InferenceErrorLike): boolean {
544544
);
545545
}
546546

547+
/**
548+
* Model-deprecation signals that veto the credential-404 classifier. A
549+
* retired-model 404 can itself carry the word "expired" ("model 'gpt-4o'
550+
* has expired — migrate to 'gpt-5'"), and reclassifying it as
551+
* credential_failure would send the operator to log in again for a model
552+
* that no longer exists. The veto needs a model mention plus a deprecation
553+
* signal so bare credential texts ("the access token expired") still
554+
* reclassify.
555+
*/
556+
const CODEX_MODEL_DEPRECATION_MARKERS = [
557+
"model_expired",
558+
"model_deprecated",
559+
"deprecated",
560+
"deprecation",
561+
"retired",
562+
"sunset",
563+
"no longer supported",
564+
"no longer available",
565+
"has expired",
566+
"end of life",
567+
] as const;
568+
569+
function looksLikeCodexModelDeprecation(error: InferenceErrorLike): boolean {
570+
const combined = [error.message ?? "", stringFromRaw(error.raw)]
571+
.join("\n")
572+
.toLowerCase();
573+
if (!combined.includes("model")) return false;
574+
return CODEX_MODEL_DEPRECATION_MARKERS.some((marker) =>
575+
combined.includes(marker),
576+
);
577+
}
578+
547579
/**
548580
* Single shared predicate behind the Codex credential-404 re-login copy: the
549581
* classifier brands with it (formatCodexCredential404Message) and the
@@ -607,7 +639,8 @@ function recordCodexCredential404Reclassification(
607639
* re-login copy matches the CodexAuthError shape so the TUI names the
608640
* affected profile through its existing auth matchers; the original
609641
* diagnostic rides along in parens so the model name stays debuggable.
610-
* Anything without an auth signal keeps the fatal switch-models path.
642+
* Anything without an auth signal keeps the fatal switch-models path, as
643+
* does a model-deprecation 404 even when it carries the word "expired".
611644
*/
612645
function normalizeCodexCredential404Error(
613646
error: InferenceErrorWithGoContext,
@@ -617,6 +650,10 @@ function normalizeCodexCredential404Error(
617650
const providerId = error.providerId;
618651
if (providerId === undefined || !isCodexProviderName(providerId))
619652
return error;
653+
// A retired model is a fatal switch-models failure, never a credential
654+
// failure: the veto runs before the auth markers so deprecation wins over
655+
// a merely expired-sounding word.
656+
if (looksLikeCodexModelDeprecation(error)) return error;
620657
if (!hasCodexCredentialAuthSignal(error)) return error;
621658
const profile = codexProfileFromProviderName(providerId) ?? providerId;
622659
const message = formatCodexCredential404Message(profile, error.message ?? "");

0 commit comments

Comments
 (0)