Skip to content

Commit 1528e33

Browse files
Merge pull request #1138 from corbitsdev/cl-8627-classify-expired-codex-credentials-as-credential-failures
fix(inference): treat Codex 404s as expired credentials
2 parents 7adb0a2 + 258eaea commit 1528e33

4 files changed

Lines changed: 389 additions & 5 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,50 @@ describe("terminalProviderFailureMessage", () => {
160160
);
161161
});
162162

163+
test("terminal Codex credential 404 names the profile with a re-login hint", () => {
164+
const normalized = normalizeInferenceErrorForTerminal(
165+
{
166+
category: "fatal",
167+
message: "Not Found",
168+
statusCode: 404,
169+
raw: {
170+
error: {
171+
code: "invalid_token",
172+
message: "Not authorized: the access token has been revoked",
173+
},
174+
},
175+
},
176+
"codex/work",
177+
);
178+
const message = terminalProviderFailureMessage("codex/work", normalized);
179+
expect(message).toContain('Codex profile "work"');
180+
expect(message).toContain("Not Found");
181+
expect(message).not.toContain("/model");
182+
// One re-login hint, not a stutter: the branded diagnostic dedups via
183+
// the shared carriesCodexReLoginHint predicate.
184+
expect(message.toLowerCase().match(/log in again/g)).toHaveLength(1);
185+
});
186+
187+
test("terminal bare Codex 404 without an auth signal keeps switch-models guidance", () => {
188+
const normalized = normalizeInferenceErrorForTerminal(
189+
{ category: "fatal", message: "Not Found", statusCode: 404 },
190+
"codex/work",
191+
);
192+
const message = terminalProviderFailureMessage("codex/work", normalized);
193+
expect(message).toContain('"/model"');
194+
expect(message.toLowerCase()).not.toMatch(/log in again/);
195+
});
196+
197+
test("terminal genuine unknown-model 404 keeps switch-models guidance", () => {
198+
const message = terminalProviderFailureMessage("codex/work", {
199+
category: "fatal",
200+
message: "The model 'gpt-99' does not exist",
201+
statusCode: 404,
202+
providerId: "codex/work",
203+
});
204+
expect(message).toContain('"/model"');
205+
});
206+
163207
test.each([
164208
{
165209
name: "Bearer header",

‎src/inference-error-message.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { stripTerminalControlSequences } from "./util/control-char-strip.js";
1515
import { scrubSecretShapedContent } from "./plugins/tool-result-secret-scrub.js";
1616
import {
17+
carriesCodexReLoginHint,
1718
gatewayOverloadUserMessage,
1819
isCodexShortRateLimitInferenceError,
1920
isGatewayOverloadInferenceError,
@@ -146,14 +147,22 @@ export function terminalProviderFailureMessage(
146147
? diagnostic
147148
: `${diagnostic}.`;
148149
const guidance = terminalProviderFailureGuidance(error, category);
149-
return `${label} Provider failed (${category}): ${diagnosticSentence} ${guidance}`;
150+
const tail = guidance.length > 0 ? ` ${guidance}` : "";
151+
return `${label} Provider failed (${category}): ${diagnosticSentence}${tail}`;
150152
}
151153

152154
function terminalProviderFailureGuidance(
153155
error: InferenceErrorLike,
154156
category: string,
155157
): string {
156-
if (category === "credential_failure") return CREDENTIAL_FAILURE_USER_MESSAGE;
158+
if (category === "credential_failure") {
159+
// Normalized credential failures already carry the re-login hint in the
160+
// diagnostic (e.g. Codex profile copy); repeating it reads as a stutter.
161+
// Shared with the classifier via carriesCodexReLoginHint — one predicate.
162+
return carriesCodexReLoginHint(error.message ?? "")
163+
? ""
164+
: CREDENTIAL_FAILURE_USER_MESSAGE;
165+
}
157166
if (category === "context_overflow") return "Try /clear to start fresh.";
158167
// A 429 that survived the harness's paced retries is a wait-it-out rate
159168
// limit, not a generic flake: say so instead of the bare "Try again."
@@ -180,7 +189,9 @@ function terminalProviderFailureSummary(
180189
): string {
181190
const label = terminalProviderFailureLabel(providerId, displayLabel);
182191
const category = terminalProviderFailureCategory(error);
183-
return `${label} Provider failed (${category}). ${terminalProviderFailureGuidance(error, category)}`;
192+
const guidance = terminalProviderFailureGuidance(error, category);
193+
const tail = guidance.length > 0 ? ` ${guidance}` : "";
194+
return `${label} Provider failed (${category}).${tail}`;
184195
}
185196

186197
export type ResolvedProviderFailureError = Error & {

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { describe, expect, test } from "bun:test";
22
import {
3+
carriesCodexReLoginHint,
4+
codexCredential404ReclassifiedStats,
35
GATEWAY_OVERLOAD_USER_MESSAGE,
46
isGatewayOverloadInferenceError,
57
looksLikeHtmlGatewayBody,
68
normalizeInferenceErrorForRetry,
9+
resetCodexCredential404StatsForTests,
710
XAI_CAPACITY_USER_MESSAGE,
811
} from "./inference-gateway-error.js";
912

@@ -279,6 +282,178 @@ describe("normalizeInferenceErrorForRetry", () => {
279282
expect(normalized).toBe(error);
280283
});
281284

285+
/**
286+
* Wire shape for a revoked Codex credential: the harness classifies the
287+
* HTTP 404 as fatal with the statusText message while the JSON body rides
288+
* on raw. The body carries the auth-rejection signal; the status line
289+
* alone ("Not Found") must never reclassify.
290+
*/
291+
const REVOKED_CREDENTIAL_404_RAW = {
292+
error: {
293+
code: "invalid_token",
294+
message: "Not authorized: the access token has been revoked",
295+
type: "invalid_request_error",
296+
},
297+
};
298+
299+
test("Codex 404 with a revoked-credential body reclassifies as credential_failure", () => {
300+
const normalized = normalizeInferenceErrorForRetry({
301+
category: "fatal",
302+
message: "Not Found",
303+
statusCode: 404,
304+
providerId: "codex/work",
305+
raw: REVOKED_CREDENTIAL_404_RAW,
306+
});
307+
expect(normalized.category).toBe("credential_failure");
308+
expect(normalized.message).toContain('Codex profile "work"');
309+
expect(carriesCodexReLoginHint(normalized.message)).toBe(true);
310+
// Original diagnostic rides along so the failure stays debuggable, and
311+
// the wire body stays on raw for logs.
312+
expect(normalized.message).toContain("Not Found");
313+
expect(normalized.raw).toEqual(REVOKED_CREDENTIAL_404_RAW);
314+
});
315+
316+
test.each([
317+
{ name: "not authorized", body: "Not authorized" },
318+
{ name: "unauthorized", body: "401 Unauthorized" },
319+
{ name: "invalid token", body: "Invalid token" },
320+
{ name: "expired", body: "The access token expired" },
321+
{ name: "revoked", body: "Token has been revoked" },
322+
])("Codex 404 with $name signal reclassifies", ({ body }) => {
323+
const normalized = normalizeInferenceErrorForRetry({
324+
category: "fatal",
325+
message: "Not Found",
326+
statusCode: 404,
327+
providerId: "codex/work",
328+
raw: { error: { message: body } },
329+
});
330+
expect(normalized.category).toBe("credential_failure");
331+
expect(carriesCodexReLoginHint(normalized.message)).toBe(true);
332+
});
333+
334+
test("Codex 404 with an auth signal in the message reclassifies", () => {
335+
const normalized = normalizeInferenceErrorForRetry({
336+
category: "fatal",
337+
message: "Invalid token: expired",
338+
statusCode: 404,
339+
providerId: "codex/work",
340+
});
341+
expect(normalized.category).toBe("credential_failure");
342+
expect(normalized.message).toContain("Invalid token: expired");
343+
});
344+
345+
test("Codex bare 404 without an auth signal stays fatal", () => {
346+
const error = {
347+
category: "fatal" as const,
348+
message: "Not Found",
349+
statusCode: 404,
350+
providerId: "codex/work",
351+
};
352+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
353+
});
354+
355+
test("Codex routing 404 without an auth signal stays fatal", () => {
356+
const error = {
357+
category: "fatal" as const,
358+
message: "Not Found",
359+
statusCode: 404,
360+
providerId: "codex/work",
361+
raw: { error: { code: "not_found", message: "No such endpoint" } },
362+
};
363+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
364+
});
365+
366+
test("Codex 404 naming a dotted unknown model stays fatal", () => {
367+
const error = {
368+
category: "fatal" as const,
369+
message: "The model 'gpt-3.5-turbo' does not exist",
370+
statusCode: 404,
371+
providerId: "codex/work",
372+
};
373+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
374+
});
375+
376+
test("Codex 404 naming an unknown model keeps fatal switch-models guidance", () => {
377+
const error = {
378+
category: "fatal" as const,
379+
message: "The model 'gpt-99' does not exist",
380+
statusCode: 404,
381+
providerId: "codex/work",
382+
raw: {
383+
error: {
384+
code: "model_not_found",
385+
message: "The model 'gpt-99' does not exist",
386+
type: "invalid_request_error",
387+
},
388+
},
389+
};
390+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
391+
});
392+
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+
414+
test("non-Codex 404 keeps fatal switch-models guidance", () => {
415+
const error = {
416+
category: "fatal" as const,
417+
message: "Not Found",
418+
statusCode: 404,
419+
providerId: "custom-provider",
420+
};
421+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
422+
});
423+
424+
test("non-Codex 404 with an auth signal keeps provider scoping", () => {
425+
const error = {
426+
category: "fatal" as const,
427+
message: "Not Found",
428+
statusCode: 404,
429+
providerId: "custom-provider",
430+
raw: { error: { message: "Token has been revoked" } },
431+
};
432+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
433+
});
434+
435+
test("reclassified Codex 404s bump the counter with a body sample", () => {
436+
resetCodexCredential404StatsForTests();
437+
expect(codexCredential404ReclassifiedStats().count).toBe(0);
438+
normalizeInferenceErrorForRetry({
439+
category: "fatal",
440+
message: "Not Found",
441+
statusCode: 404,
442+
providerId: "codex/work",
443+
raw: REVOKED_CREDENTIAL_404_RAW,
444+
});
445+
// A fatal 404 without an auth signal must not bump the counter.
446+
normalizeInferenceErrorForRetry({
447+
category: "fatal",
448+
message: "Not Found",
449+
statusCode: 404,
450+
providerId: "codex/work",
451+
});
452+
const stats = codexCredential404ReclassifiedStats();
453+
expect(stats.count).toBe(1);
454+
expect(stats.lastSample).toContain("revoked");
455+
});
456+
282457
test("known-xAI message-only capacity protocol error becomes retryable", () => {
283458
const normalized = normalizeInferenceErrorForRetry({
284459
category: "protocol_mismatch",

0 commit comments

Comments
 (0)