Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
213 lines
7.5 KiB
TypeScript
213 lines
7.5 KiB
TypeScript
import type { ChatMessage } from './types';
|
|
|
|
type ErrorInfo = NonNullable<ChatMessage['errorInfo']>;
|
|
|
|
/**
|
|
* Extract the human-readable message from anything that might surface as an
|
|
* error (Error instance, string, SDK error object with `.message`, etc.).
|
|
*/
|
|
function extractMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message || '';
|
|
if (typeof error === 'string') return error;
|
|
if (error && typeof error === 'object' && 'message' in error) {
|
|
const m = (error as { message: unknown }).message;
|
|
if (typeof m === 'string') return m;
|
|
}
|
|
try {
|
|
return JSON.stringify(error) ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull the HTTP status code out of an error when the SDK layer attached one.
|
|
* Vercel AI SDK's APICallError exposes `.statusCode`; some shims use
|
|
* `.status` or `.cause.statusCode`. Falls back to parsing the message text
|
|
* when no structured field is available.
|
|
*/
|
|
function extractStatusCode(error: unknown, message: string): number | undefined {
|
|
if (error && typeof error === 'object') {
|
|
const obj = error as Record<string, unknown>;
|
|
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
|
if (typeof obj.status === 'number') return obj.status;
|
|
if (obj.cause && typeof obj.cause === 'object') {
|
|
const causeStatus = (obj.cause as Record<string, unknown>).statusCode;
|
|
if (typeof causeStatus === 'number') return causeStatus;
|
|
}
|
|
}
|
|
const statusPatterns = [
|
|
/\bHTTP\s*(4\d{2}|5\d{2})\b/i,
|
|
/\bstatus(?:Code)?\s*[:=]?\s*(4\d{2}|5\d{2})\b/i,
|
|
/\bcode\s*[:=]\s*(4\d{2}|5\d{2})\b/i,
|
|
/^\s*(4\d{2}|5\d{2})\b(?!\s*ms\b)/i,
|
|
];
|
|
for (const pattern of statusPatterns) {
|
|
const match = message.match(pattern);
|
|
if (match) return Number(match[1]);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Pull the response body out of an error object if the SDK attached it.
|
|
* Nginx / CDN proxy error pages ship as HTML, so we can detect them here.
|
|
*/
|
|
function extractResponseBody(error: unknown): string | undefined {
|
|
if (!error || typeof error !== 'object') return undefined;
|
|
const body = (error as Record<string, unknown>).responseBody;
|
|
if (typeof body === 'string') return body;
|
|
return undefined;
|
|
}
|
|
|
|
export function isRequestTooLargeError(error: unknown): boolean {
|
|
const message = extractMessage(error).trim();
|
|
const responseBody = extractResponseBody(error) ?? "";
|
|
const combined = `${message}\n${responseBody}`;
|
|
const statusCode = extractStatusCode(error, combined);
|
|
return (
|
|
statusCode === 413 ||
|
|
/\brequest entity too large\b/i.test(combined) ||
|
|
/\b413\b(?!\s*ms\b).*\b(payload|request|too large|entity)\b/i.test(combined)
|
|
);
|
|
}
|
|
|
|
function looksLikeHtml(text: string): boolean {
|
|
if (!text) return false;
|
|
const lower = text.toLowerCase();
|
|
const trimmedStart = lower.trimStart().slice(0, 200);
|
|
// Start-of-body: responseBody captured verbatim by the SDK lands here.
|
|
if (
|
|
trimmedStart.startsWith('<!doctype html') ||
|
|
trimmedStart.startsWith('<html') ||
|
|
trimmedStart.startsWith('<head') ||
|
|
trimmedStart.startsWith('<body')
|
|
) {
|
|
return true;
|
|
}
|
|
// Embedded: some SDKs wrap the HTML body inside an error message like
|
|
// "Parse failed: <html>...". Look for unmistakable HTML tags anywhere
|
|
// in the text. Kept narrow to avoid flagging errors that casually
|
|
// mention "html" as a word.
|
|
if (
|
|
lower.includes('<!doctype html') ||
|
|
lower.includes('<html>') ||
|
|
lower.includes('<html ') ||
|
|
// Common nginx default error-page opener.
|
|
/<center>\s*<h1>/.test(lower)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function looksLikeZodParseError(message: string): boolean {
|
|
// Zod and Vercel AI SDK schema errors look like:
|
|
// Expected 'id' to be a string.
|
|
// Expected 'choices' to be an array.
|
|
// Invalid JSON response: ...
|
|
// Type validation failed: ...
|
|
return (
|
|
/\bExpected '[^']+' to be (a|an) /i.test(message) ||
|
|
/\binvalid json response\b/i.test(message) ||
|
|
/\btype validation failed\b/i.test(message)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Map an arbitrary error surface to display-safe error info shown in the
|
|
* chat UI. Known hostile scenarios get a concrete, actionable message; the
|
|
* raw SDK text is appended so users can still report it verbatim.
|
|
*
|
|
* Covers:
|
|
* - HTTP 413 (proxy request-size limit, e.g. nginx client_max_body_size)
|
|
* - HTTP 502/504 (upstream proxy failures)
|
|
* - HTML error page returned in place of JSON (any proxy)
|
|
* - Schema/parse failures ("Expected 'id' to be a string.") that typically
|
|
* mean the server swapped the response body for an error page
|
|
*/
|
|
export function classifyError(error: unknown): ErrorInfo {
|
|
const rawMessage = extractMessage(error).trim() || 'Unknown error';
|
|
const statusCode = extractStatusCode(error, rawMessage);
|
|
const responseBody = extractResponseBody(error);
|
|
|
|
const hasHtml =
|
|
looksLikeHtml(rawMessage) ||
|
|
(responseBody !== undefined && looksLikeHtml(responseBody));
|
|
const looksLikeParseError = looksLikeZodParseError(rawMessage);
|
|
|
|
const sanitizedRaw = sanitizeErrorMessage(rawMessage);
|
|
|
|
if (isRequestTooLargeError(error)) {
|
|
return {
|
|
type: 'network',
|
|
message:
|
|
`Request too large (HTTP 413). The AI gateway rejected the payload — this usually means ` +
|
|
`the request body exceeded the proxy's size limit (for example nginx \`client_max_body_size\`). ` +
|
|
`Try sending a shorter message, fewer/smaller attachments, or raising the proxy limit.\n\n` +
|
|
`Raw: ${sanitizedRaw}`,
|
|
retryable: false,
|
|
};
|
|
}
|
|
|
|
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
|
|
return {
|
|
type: 'network',
|
|
message:
|
|
`AI gateway error (HTTP ${statusCode}). The proxy in front of the provider returned an error — ` +
|
|
`the upstream AI service may be unreachable or timing out.\n\n` +
|
|
`Raw: ${sanitizedRaw}`,
|
|
retryable: true,
|
|
};
|
|
}
|
|
|
|
if (hasHtml) {
|
|
return {
|
|
type: 'provider',
|
|
message:
|
|
`The server returned an HTML error page instead of a JSON response. ` +
|
|
`This almost always means a proxy (nginx / CDN / gateway) between you and the AI provider ` +
|
|
`intercepted the request — commonly due to a size limit, auth failure, or the upstream service being down.\n\n` +
|
|
`Raw: ${sanitizedRaw}`,
|
|
retryable: false,
|
|
};
|
|
}
|
|
|
|
if (looksLikeParseError) {
|
|
return {
|
|
type: 'provider',
|
|
message:
|
|
`The AI response could not be parsed as a valid chat completion. ` +
|
|
`A proxy may have replaced or truncated the response body, or the provider returned a non-standard format. ` +
|
|
`If you just sent a large request, check for a request-size limit on any intermediate proxy.\n\n` +
|
|
`Raw: ${sanitizedRaw}`,
|
|
retryable: false,
|
|
};
|
|
}
|
|
|
|
return { type: 'unknown', message: sanitizedRaw, retryable: false };
|
|
}
|
|
|
|
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
|
|
/**
|
|
* Sanitize an error message before displaying it to the user.
|
|
* Strips file paths, URLs with credentials, and truncates long messages.
|
|
*/
|
|
export function sanitizeErrorMessage(msg: string): string {
|
|
let sanitized = msg;
|
|
|
|
// Strip file system paths (Unix and Windows)
|
|
sanitized = sanitized.replace(/(?:\/Users\/|\/home\/|\/tmp\/|\/var\/|[A-Z]:\\)[^\s"'`,;)}\]>]*/gi, '<path>');
|
|
|
|
// Strip URLs containing API keys or tokens in query params
|
|
sanitized = sanitized.replace(/https?:\/\/[^\s"']*[?&](key|token|api_key|apikey|secret|access_token|auth)=[^\s"'&]*/gi, '<url-redacted>');
|
|
|
|
// Truncate overly long messages
|
|
if (sanitized.length > MAX_ERROR_MESSAGE_LENGTH) {
|
|
sanitized = sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) + '...';
|
|
}
|
|
|
|
return sanitized;
|
|
}
|