Skip to content

Commit 057c290

Browse files
committed
feat(providers): offer reconnect for auth-class inference failures
1 parent 3a1d5f8 commit 057c290

25 files changed

Lines changed: 1873 additions & 36 deletions

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

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22

33
import { normalizeInferenceErrorForTerminal } from "./inference-gateway-error.js";
44
import {
5+
CREDENTIAL_FAILURE_USER_MESSAGE,
56
inferenceErrorMessage,
67
terminalProviderFailureMessage,
78
} from "./inference-error-message.js";
@@ -200,6 +201,155 @@ describe("terminalProviderFailureMessage", () => {
200201
expect(message.toLowerCase()).not.toContain("retrying");
201202
});
202203

204+
test("terminal xAI OAuth 426 names the profile and spells the reconnect command", () => {
205+
const normalized = normalizeInferenceErrorForTerminal(
206+
{
207+
category: "fatal",
208+
message: "Upgrade Required",
209+
statusCode: 426,
210+
raw: { error: { code: "upgrade_required" } },
211+
},
212+
"xai/default-2",
213+
);
214+
const message = terminalProviderFailureMessage("xai/default-2", normalized);
215+
expect(message).toContain('xAI profile "default-2"');
216+
expect(message).toContain('"/connect xai default-2"');
217+
expect(message).toContain('reconnect profile "default-2"');
218+
});
219+
220+
test("terminal xAI 401 credential failure spells the reconnect command", () => {
221+
const message = terminalProviderFailureMessage("xai/default-2", {
222+
category: "credential_failure",
223+
message: '{"error":{"code":401}}',
224+
statusCode: 401,
225+
providerId: "xai/default-2",
226+
});
227+
expect(message).toContain("/connect");
228+
expect(message).toContain('"/connect xai default-2"');
229+
expect(message).toContain('reconnect profile "default-2"');
230+
});
231+
232+
test("terminal Codex credential failure keeps branded wording plus the explicit command", () => {
233+
const normalized = normalizeInferenceErrorForTerminal(
234+
{
235+
category: "fatal",
236+
message: "Not Found",
237+
statusCode: 404,
238+
raw: {
239+
error: {
240+
code: "invalid_token",
241+
message: "Not authorized: the access token has been revoked",
242+
},
243+
},
244+
},
245+
"codex/work",
246+
);
247+
const message = terminalProviderFailureMessage("codex/work", normalized);
248+
expect(message).toContain('Codex profile "work"');
249+
// Branded line keeps its wording (bare /connect); the explicit command is
250+
// additive, never a stutter of the generic line.
251+
expect(message).toContain("/connect");
252+
expect(message).toContain('"/connect codex work"');
253+
expect(message).not.toContain("run /connect to reconnect");
254+
});
255+
256+
test.each([
257+
{
258+
name: "bare 404",
259+
providerId: "codex/work",
260+
error: {
261+
category: "fatal" as const,
262+
message: "Not Found",
263+
statusCode: 404,
264+
},
265+
exact:
266+
'codex/work Provider failed (fatal): Not Found. Try again or switch models with "/model".',
267+
},
268+
{
269+
name: "quota",
270+
providerId: "xai/alice",
271+
error: {
272+
category: "quota_exhausted" as const,
273+
message: "Too Many Requests",
274+
statusCode: 429,
275+
},
276+
exact:
277+
"xai/alice Provider failed (retryable): Rate limited. Wait a moment and try again.",
278+
},
279+
{
280+
name: "overflow",
281+
providerId: "xai/alice",
282+
error: {
283+
category: "context_overflow" as const,
284+
message: "context length exceeded",
285+
},
286+
exact:
287+
"xai/alice Provider failed (context_overflow): context length exceeded. Try /clear to start fresh.",
288+
},
289+
{
290+
name: "non-OAuth 426",
291+
providerId: "custom-provider",
292+
error: {
293+
category: "fatal" as const,
294+
message: "Upgrade Required",
295+
statusCode: 426,
296+
},
297+
exact:
298+
'custom-provider Provider failed (fatal): Upgrade Required. Try again or switch models with "/model".',
299+
},
300+
])(
301+
"non-reconnect $name keeps byte-identical guidance",
302+
({ providerId, error, exact }) => {
303+
const normalized = normalizeInferenceErrorForTerminal(error, providerId);
304+
const message = terminalProviderFailureMessage(providerId, normalized);
305+
expect(message).toBe(exact);
306+
expect(message).not.toContain('"/connect ');
307+
},
308+
);
309+
310+
test.each([
311+
{
312+
providerId: "xai/alice",
313+
command: '"/connect xai alice"',
314+
profile: "alice",
315+
},
316+
{
317+
providerId: "codex/alice",
318+
command: '"/connect codex alice"',
319+
profile: "alice",
320+
},
321+
])(
322+
"reconnect-class $providerId spells the explicit reconnect command",
323+
({ providerId, command, profile }) => {
324+
const normalized = normalizeInferenceErrorForTerminal(
325+
{
326+
category: "credential_failure",
327+
message: "Unauthorized",
328+
statusCode: 401,
329+
},
330+
providerId,
331+
);
332+
const message = terminalProviderFailureMessage(providerId, normalized);
333+
expect(message).toContain(
334+
`Run ${command} to reconnect profile "${profile}".`,
335+
);
336+
},
337+
);
338+
339+
test("bare kind-only id is not reconnect-class and keeps base guidance", () => {
340+
const normalized = normalizeInferenceErrorForTerminal(
341+
{
342+
category: "credential_failure",
343+
message: "Unauthorized",
344+
statusCode: 401,
345+
},
346+
"xai",
347+
);
348+
const message = terminalProviderFailureMessage("xai", normalized);
349+
expect(message).not.toContain('"/connect ');
350+
expect(message).toContain(CREDENTIAL_FAILURE_USER_MESSAGE);
351+
});
352+
203353
test("retryable 429 guidance asks the operator to wait before trying again", () => {
204354
const message = terminalProviderFailureMessage("codex/default", {
205355
category: "retryable",

‎src/inference-error-message.ts‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
gatewayOverloadUserMessage,
1919
isCodexShortRateLimitInferenceError,
2020
isGatewayOverloadInferenceError,
21+
isKnownOAuthProviderId,
2122
isXaiShortRateLimitInferenceError,
2223
parseCodexUsageLimitFromError,
2324
RATE_LIMIT_USER_MESSAGE,
@@ -167,22 +168,71 @@ export function terminalProviderFailureMessage(
167168
const diagnosticSentence = /[.!?]$/.test(diagnostic)
168169
? diagnostic
169170
: `${diagnostic}.`;
170-
const guidance = terminalProviderFailureGuidance(error, category);
171+
const guidance = terminalProviderFailureGuidance(error, category, providerId);
171172
const tail = guidance.length > 0 ? ` ${guidance}` : "";
172173
return `${label} Provider failed (${category}): ${diagnosticSentence}${tail}`;
173174
}
174175

176+
/**
177+
* Split a `kind/name` provider id into its reconnect scope. Unslashed ids
178+
* reconnect the `default` profile. Returns undefined for malformed ids so the
179+
* guidance never spells a broken command. Shared with the reconnect
180+
* descriptor (Phase 3) — one split, not two.
181+
*/
182+
export function splitReconnectScope(
183+
providerId: string,
184+
): { kind: string; profile: string } | undefined {
185+
const slash = providerId.indexOf("/");
186+
if (slash <= 0) {
187+
return providerId.length > 0
188+
? { kind: providerId, profile: "default" }
189+
: undefined;
190+
}
191+
const kind = providerId.slice(0, slash);
192+
const profile = providerId.slice(slash + 1);
193+
if (kind.length === 0 || profile.length === 0) return undefined;
194+
return { kind, profile };
195+
}
196+
197+
/**
198+
* Explicit one-action reconnect command for reconnect-class terminal failures
199+
* (credential_failure on a known-OAuth id): `Run "/connect <kind> <profile>"
200+
* to reconnect profile "<profile>".` Empty for anything else, and empty when
201+
* the diagnostic already carries the explicit command — never a stutter.
202+
*/
203+
function reconnectCommandGuidance(
204+
providerId: string,
205+
diagnosticMessage: string,
206+
): string {
207+
if (!isKnownOAuthProviderId(providerId)) return "";
208+
const scope = splitReconnectScope(providerId);
209+
if (scope === undefined) return "";
210+
const command = `"/connect ${scope.kind} ${scope.profile}"`;
211+
if (diagnosticMessage.includes(command)) return "";
212+
return `Run ${command} to reconnect profile "${scope.profile}".`;
213+
}
214+
175215
function terminalProviderFailureGuidance(
176216
error: InferenceErrorLike,
177217
category: string,
218+
providerId: string,
178219
): string {
179220
if (category === "credential_failure") {
180221
// Normalized credential failures already carry the re-login hint in the
181222
// diagnostic (e.g. Codex profile copy); repeating it reads as a stutter.
182223
// Shared with the classifier via carriesCodexReLoginHint — one predicate.
183-
return carriesCodexReLoginHint(error.message ?? "")
224+
const base = carriesCodexReLoginHint(error.message ?? "")
184225
? ""
185226
: CREDENTIAL_FAILURE_USER_MESSAGE;
227+
// Reconnect-class failures additionally spell the explicit command so the
228+
// operator can repair the named profile in one action (issue #1295). The
229+
// Codex branded line keeps its wording; the explicit command is additive.
230+
const explicit = reconnectCommandGuidance(
231+
error.providerId ?? providerId,
232+
error.message ?? "",
233+
);
234+
if (explicit.length === 0) return base;
235+
return base.length > 0 ? `${base} ${explicit}` : explicit;
186236
}
187237
if (category === "context_overflow") return "Try /clear to start fresh.";
188238
// A 429 that survived the harness's paced retries is a wait-it-out rate
@@ -210,7 +260,7 @@ function terminalProviderFailureSummary(
210260
): string {
211261
const label = terminalProviderFailureLabel(providerId, displayLabel);
212262
const category = terminalProviderFailureCategory(error);
213-
const guidance = terminalProviderFailureGuidance(error, category);
263+
const guidance = terminalProviderFailureGuidance(error, category, providerId);
214264
const tail = guidance.length > 0 ? ` ${guidance}` : "";
215265
return `${label} Provider failed (${category}).${tail}`;
216266
}

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ const CLOUDFLARE_503_HTML = `<!DOCTYPE html>
1212
<body><h1>503 Service Temporarily Unavailable</h1>
1313
<p>Cloudflare Ray ID: abc</p></body></html>`;
1414

15+
// PROVISIONAL (Phase 0, issue #1295): the real xAI 426 body for xai/default-2
16+
// is unknown — the issue reports a bare "HTTP 426 Upgrade Required". Grounded
17+
// in the status code, the reason phrase, and the OAuth provider id only; no
18+
// body-signal assertions until a real payload lands.
19+
const PROVISIONAL_XAI_426_UPGRADE_REQUIRED = {
20+
category: "fatal" as const,
21+
message: "Upgrade Required",
22+
statusCode: 426,
23+
providerId: "xai/default-2",
24+
raw: { error: { code: "upgrade_required", message: "Upgrade Required" } },
25+
};
26+
1527
describe("looksLikeHtmlGatewayBody", () => {
1628
test("detects doctype HTML", () => {
1729
expect(looksLikeHtmlGatewayBody(CLOUDFLARE_503_HTML)).toBe(true);
@@ -73,6 +85,120 @@ describe("normalizeInferenceErrorForRetry", () => {
7385
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
7486
});
7587

88+
// Phase 1 (issue #1295): normalizeOAuthUpgradeRequiredError grounds the
89+
// provisional marker list — OAuth 426 with upgrade signal is reconnect-class.
90+
test("PROVISIONAL: xAI OAuth 426 with upgrade signal normalizes to credential_failure", () => {
91+
const normalized = normalizeInferenceErrorForRetry(
92+
PROVISIONAL_XAI_426_UPGRADE_REQUIRED,
93+
);
94+
expect(normalized.category).toBe("credential_failure");
95+
expect(normalized.statusCode).toBe(426);
96+
expect(normalized.message).toContain('xAI profile "default-2"');
97+
expect(normalized.message).toContain("/connect");
98+
expect(normalized.raw).toEqual(PROVISIONAL_XAI_426_UPGRADE_REQUIRED.raw);
99+
});
100+
101+
test("xAI OAuth 426 with raw-body auth signal names the profile", () => {
102+
const normalized = normalizeInferenceErrorForRetry({
103+
category: "fatal",
104+
message: "",
105+
statusCode: 426,
106+
providerId: "xai/default-2",
107+
raw: { error: { message: "Not authorized: token revoked" } },
108+
});
109+
expect(normalized.category).toBe("credential_failure");
110+
expect(normalized.message).toContain('xAI profile "default-2"');
111+
});
112+
113+
test("Codex OAuth 426 with auth signal reuses the branded re-login line", () => {
114+
const normalized = normalizeInferenceErrorForRetry({
115+
category: "fatal",
116+
message: "Upgrade Required",
117+
statusCode: 426,
118+
providerId: "codex/work",
119+
raw: { error: { code: "invalid_token" } },
120+
});
121+
expect(normalized.category).toBe("credential_failure");
122+
expect(normalized.message).toContain('Codex profile "work"');
123+
expect(carriesCodexReLoginHint(normalized.message)).toBe(true);
124+
});
125+
126+
test("OAuth 426 without body signal is still reconnect-class on an OAuth id", () => {
127+
const normalized = normalizeInferenceErrorForRetry({
128+
category: "fatal",
129+
message: "",
130+
statusCode: 426,
131+
providerId: "xai/default-2",
132+
});
133+
expect(normalized.category).toBe("credential_failure");
134+
expect(normalized.statusCode).toBe(426);
135+
expect(normalized.message).toContain('xAI profile "default-2"');
136+
});
137+
138+
test.each([
139+
{
140+
name: "API-key 426",
141+
error: {
142+
category: "fatal" as const,
143+
message: "Upgrade Required",
144+
statusCode: 426,
145+
providerId: "openai/prod",
146+
},
147+
},
148+
{
149+
name: "custom-provider 426",
150+
error: {
151+
category: "fatal" as const,
152+
message: "Upgrade Required",
153+
statusCode: 426,
154+
providerId: "custom-provider",
155+
},
156+
},
157+
{
158+
name: "provider-less 426",
159+
error: {
160+
category: "fatal" as const,
161+
message: "Upgrade Required",
162+
statusCode: 426,
163+
},
164+
},
165+
{
166+
name: "bare OAuth 426 without signal on a non-OAuth id",
167+
error: {
168+
category: "fatal" as const,
169+
message: "",
170+
statusCode: 426,
171+
providerId: "openai/prod",
172+
},
173+
},
174+
])("non-OAuth $name stays fatal", ({ error }) => {
175+
expect(normalizeInferenceErrorForRetry(error)).toEqual(error);
176+
});
177+
178+
test("OAuth 426 with quota text stays fatal", () => {
179+
const err = {
180+
category: "fatal" as const,
181+
message: "Upgrade Required",
182+
statusCode: 426,
183+
providerId: "xai/default-2",
184+
raw: { error: { message: "exceeded your current quota" } },
185+
};
186+
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
187+
});
188+
189+
test("OAuth 426 with deprecation text stays fatal", () => {
190+
const err = {
191+
category: "fatal" as const,
192+
message: "Upgrade Required",
193+
statusCode: 426,
194+
providerId: "xai/default-2",
195+
raw: {
196+
error: { message: "model 'grok-x' has expired — migrate to 'grok-y'" },
197+
},
198+
};
199+
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
200+
});
201+
76202
test("maps GoUsageLimitError 429 to quota_exhausted", () => {
77203
const normalized = normalizeInferenceErrorForRetry({
78204
category: "retryable",

0 commit comments

Comments
 (0)