Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/inference-error-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";

import { normalizeInferenceErrorForTerminal } from "./inference-gateway-error.js";
import {
CREDENTIAL_FAILURE_USER_MESSAGE,
inferenceErrorMessage,
terminalProviderFailureMessage,
} from "./inference-error-message.js";
Expand Down Expand Up @@ -200,6 +201,155 @@ describe("terminalProviderFailureMessage", () => {
expect(message.toLowerCase()).not.toContain("retrying");
});

test("terminal xAI OAuth 426 names the profile and spells the reconnect command", () => {
const normalized = normalizeInferenceErrorForTerminal(
{
category: "fatal",
message: "Upgrade Required",
statusCode: 426,
raw: { error: { code: "upgrade_required" } },
},
"xai/default-2",
);
const message = terminalProviderFailureMessage("xai/default-2", normalized);
expect(message).toContain('xAI profile "default-2"');
expect(message).toContain('"/connect xai default-2"');
expect(message).toContain('reconnect profile "default-2"');
});

test("terminal xAI 401 credential failure spells the reconnect command", () => {
const message = terminalProviderFailureMessage("xai/default-2", {
category: "credential_failure",
message: '{"error":{"code":401}}',
statusCode: 401,
providerId: "xai/default-2",
});
expect(message).toContain("/connect");
expect(message).toContain('"/connect xai default-2"');
expect(message).toContain('reconnect profile "default-2"');
});

test("terminal Codex credential failure keeps branded wording plus the explicit command", () => {
const normalized = normalizeInferenceErrorForTerminal(
{
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"');
// Branded line keeps its wording (bare /connect); the explicit command is
// additive, never a stutter of the generic line.
expect(message).toContain("/connect");
expect(message).toContain('"/connect codex work"');
expect(message).not.toContain("run /connect to reconnect");
});

test.each([
{
name: "bare 404",
providerId: "codex/work",
error: {
category: "fatal" as const,
message: "Not Found",
statusCode: 404,
},
exact:
'codex/work Provider failed (fatal): Not Found. Try again or switch models with "/model".',
},
{
name: "quota",
providerId: "xai/alice",
error: {
category: "quota_exhausted" as const,
message: "Too Many Requests",
statusCode: 429,
},
exact:
"xai/alice Provider failed (retryable): Rate limited. Wait a moment and try again.",
},
{
name: "overflow",
providerId: "xai/alice",
error: {
category: "context_overflow" as const,
message: "context length exceeded",
},
exact:
"xai/alice Provider failed (context_overflow): context length exceeded. Try /clear to start fresh.",
},
{
name: "non-OAuth 426",
providerId: "custom-provider",
error: {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
},
exact:
'custom-provider Provider failed (fatal): Upgrade Required. Try again or switch models with "/model".',
},
])(
"non-reconnect $name keeps byte-identical guidance",
({ providerId, error, exact }) => {
const normalized = normalizeInferenceErrorForTerminal(error, providerId);
const message = terminalProviderFailureMessage(providerId, normalized);
expect(message).toBe(exact);
expect(message).not.toContain('"/connect ');
},
);

test.each([
{
providerId: "xai/alice",
command: '"/connect xai alice"',
profile: "alice",
},
{
providerId: "codex/alice",
command: '"/connect codex alice"',
profile: "alice",
},
])(
"reconnect-class $providerId spells the explicit reconnect command",
({ providerId, command, profile }) => {
const normalized = normalizeInferenceErrorForTerminal(
{
category: "credential_failure",
message: "Unauthorized",
statusCode: 401,
},
providerId,
);
const message = terminalProviderFailureMessage(providerId, normalized);
expect(message).toContain(
`Run ${command} to reconnect profile "${profile}".`,
);
},
);

test("bare kind-only id is not reconnect-class and keeps base guidance", () => {
const normalized = normalizeInferenceErrorForTerminal(
{
category: "credential_failure",
message: "Unauthorized",
statusCode: 401,
},
"xai",
);
const message = terminalProviderFailureMessage("xai", normalized);
expect(message).not.toContain('"/connect ');
expect(message).toContain(CREDENTIAL_FAILURE_USER_MESSAGE);
});

test("retryable 429 guidance asks the operator to wait before trying again", () => {
const message = terminalProviderFailureMessage("codex/default", {
category: "retryable",
Expand Down
56 changes: 53 additions & 3 deletions src/inference-error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
gatewayOverloadUserMessage,
isCodexShortRateLimitInferenceError,
isGatewayOverloadInferenceError,
isKnownOAuthProviderId,
isXaiShortRateLimitInferenceError,
parseCodexUsageLimitFromError,
RATE_LIMIT_USER_MESSAGE,
Expand Down Expand Up @@ -167,22 +168,71 @@ export function terminalProviderFailureMessage(
const diagnosticSentence = /[.!?]$/.test(diagnostic)
? diagnostic
: `${diagnostic}.`;
const guidance = terminalProviderFailureGuidance(error, category);
const guidance = terminalProviderFailureGuidance(error, category, providerId);
const tail = guidance.length > 0 ? ` ${guidance}` : "";
return `${label} Provider failed (${category}): ${diagnosticSentence}${tail}`;
}

/**
* Split a `kind/name` provider id into its reconnect scope. Unslashed ids
* reconnect the `default` profile. Returns undefined for malformed ids so the
* guidance never spells a broken command. Shared with the reconnect
* descriptor (Phase 3) — one split, not two.
*/
export function splitReconnectScope(
providerId: string,
): { kind: string; profile: string } | undefined {
const slash = providerId.indexOf("/");
if (slash <= 0) {
return providerId.length > 0
? { kind: providerId, profile: "default" }
: undefined;
}
const kind = providerId.slice(0, slash);
const profile = providerId.slice(slash + 1);
if (kind.length === 0 || profile.length === 0) return undefined;
return { kind, profile };
}

/**
* Explicit one-action reconnect command for reconnect-class terminal failures
* (credential_failure on a known-OAuth id): `Run "/connect <kind> <profile>"
* to reconnect profile "<profile>".` Empty for anything else, and empty when
* the diagnostic already carries the explicit command — never a stutter.
*/
function reconnectCommandGuidance(
providerId: string,
diagnosticMessage: string,
): string {
if (!isKnownOAuthProviderId(providerId)) return "";
const scope = splitReconnectScope(providerId);
if (scope === undefined) return "";
const command = `"/connect ${scope.kind} ${scope.profile}"`;
if (diagnosticMessage.includes(command)) return "";
return `Run ${command} to reconnect profile "${scope.profile}".`;
}

function terminalProviderFailureGuidance(
error: InferenceErrorLike,
category: string,
providerId: string,
): string {
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.
// Shared with the classifier via carriesCodexReLoginHint — one predicate.
return carriesCodexReLoginHint(error.message ?? "")
const base = carriesCodexReLoginHint(error.message ?? "")
? ""
: CREDENTIAL_FAILURE_USER_MESSAGE;
// Reconnect-class failures additionally spell the explicit command so the
// operator can repair the named profile in one action (issue #1295). The
// Codex branded line keeps its wording; the explicit command is additive.
const explicit = reconnectCommandGuidance(
error.providerId ?? providerId,
error.message ?? "",
);
if (explicit.length === 0) return base;
return base.length > 0 ? `${base} ${explicit}` : explicit;
}
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
Expand Down Expand Up @@ -210,7 +260,7 @@ function terminalProviderFailureSummary(
): string {
const label = terminalProviderFailureLabel(providerId, displayLabel);
const category = terminalProviderFailureCategory(error);
const guidance = terminalProviderFailureGuidance(error, category);
const guidance = terminalProviderFailureGuidance(error, category, providerId);
const tail = guidance.length > 0 ? ` ${guidance}` : "";
return `${label} Provider failed (${category}).${tail}`;
}
Expand Down
126 changes: 126 additions & 0 deletions src/inference-gateway-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ const CLOUDFLARE_503_HTML = `<!DOCTYPE html>
<body><h1>503 Service Temporarily Unavailable</h1>
<p>Cloudflare Ray ID: abc</p></body></html>`;

// PROVISIONAL (Phase 0, issue #1295): the real xAI 426 body for xai/default-2
// is unknown — the issue reports a bare "HTTP 426 Upgrade Required". Grounded
// in the status code, the reason phrase, and the OAuth provider id only; no
// body-signal assertions until a real payload lands.
const PROVISIONAL_XAI_426_UPGRADE_REQUIRED = {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
providerId: "xai/default-2",
raw: { error: { code: "upgrade_required", message: "Upgrade Required" } },
};

describe("looksLikeHtmlGatewayBody", () => {
test("detects doctype HTML", () => {
expect(looksLikeHtmlGatewayBody(CLOUDFLARE_503_HTML)).toBe(true);
Expand Down Expand Up @@ -73,6 +85,120 @@ describe("normalizeInferenceErrorForRetry", () => {
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
});

// Phase 1 (issue #1295): normalizeOAuthUpgradeRequiredError grounds the
// provisional marker list — OAuth 426 with upgrade signal is reconnect-class.
test("PROVISIONAL: xAI OAuth 426 with upgrade signal normalizes to credential_failure", () => {
const normalized = normalizeInferenceErrorForRetry(
PROVISIONAL_XAI_426_UPGRADE_REQUIRED,
);
expect(normalized.category).toBe("credential_failure");
expect(normalized.statusCode).toBe(426);
expect(normalized.message).toContain('xAI profile "default-2"');
expect(normalized.message).toContain("/connect");
expect(normalized.raw).toEqual(PROVISIONAL_XAI_426_UPGRADE_REQUIRED.raw);
});

test("xAI OAuth 426 with raw-body auth signal names the profile", () => {
const normalized = normalizeInferenceErrorForRetry({
category: "fatal",
message: "",
statusCode: 426,
providerId: "xai/default-2",
raw: { error: { message: "Not authorized: token revoked" } },
});
expect(normalized.category).toBe("credential_failure");
expect(normalized.message).toContain('xAI profile "default-2"');
});

test("Codex OAuth 426 with auth signal reuses the branded re-login line", () => {
const normalized = normalizeInferenceErrorForRetry({
category: "fatal",
message: "Upgrade Required",
statusCode: 426,
providerId: "codex/work",
raw: { error: { code: "invalid_token" } },
});
expect(normalized.category).toBe("credential_failure");
expect(normalized.message).toContain('Codex profile "work"');
expect(carriesCodexReLoginHint(normalized.message)).toBe(true);
});

test("OAuth 426 without body signal is still reconnect-class on an OAuth id", () => {
const normalized = normalizeInferenceErrorForRetry({
category: "fatal",
message: "",
statusCode: 426,
providerId: "xai/default-2",
});
expect(normalized.category).toBe("credential_failure");
expect(normalized.statusCode).toBe(426);
expect(normalized.message).toContain('xAI profile "default-2"');
});

test.each([
{
name: "API-key 426",
error: {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
providerId: "openai/prod",
},
},
{
name: "custom-provider 426",
error: {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
providerId: "custom-provider",
},
},
{
name: "provider-less 426",
error: {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
},
},
{
name: "bare OAuth 426 without signal on a non-OAuth id",
error: {
category: "fatal" as const,
message: "",
statusCode: 426,
providerId: "openai/prod",
},
},
])("non-OAuth $name stays fatal", ({ error }) => {
expect(normalizeInferenceErrorForRetry(error)).toEqual(error);
});

test("OAuth 426 with quota text stays fatal", () => {
const err = {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
providerId: "xai/default-2",
raw: { error: { message: "exceeded your current quota" } },
};
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
});

test("OAuth 426 with deprecation text stays fatal", () => {
const err = {
category: "fatal" as const,
message: "Upgrade Required",
statusCode: 426,
providerId: "xai/default-2",
raw: {
error: { message: "model 'grok-x' has expired — migrate to 'grok-y'" },
},
};
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
});

test("maps GoUsageLimitError 429 to quota_exhausted", () => {
const normalized = normalizeInferenceErrorForRetry({
category: "retryable",
Expand Down
Loading
Loading