import type { ChatMessage } from './types'; type ErrorInfo = NonNullable; /** * 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; 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).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).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('...". 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('') || lower.includes('\s*

/.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, ''); // 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, ''); // Truncate overly long messages if (sanitized.length > MAX_ERROR_MESSAGE_LENGTH) { sanitized = sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) + '...'; } return sanitized; }