// Shared normalization for provider HTTP error objects. // // Endpoints behind a proxy / gateway may return a non-2xx response whose body // the provider SDK cannot fold into `error.message`. The SDK error object still // carries the HTTP status and the raw/parsed body, but under SDK-specific field // names. Provider catch blocks that read only `error.message` therefore drop // the body and surface opaque messages like `"403 status code (no body)"` or // collapse to `"Unknown: UnknownError"`. // // `normalizeProviderError` probes the known SDK field shapes (Mistral, // `openai`, `@google/genai`, AWS Bedrock) and returns a struct each provider // composes into its display string. The `messageCarriesBody` flag captures the // Anthropic / `@google/genai` happy path where the SDK already folded the body // into the message, so providers can preserve it without double-printing. export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000; export interface NormalizedProviderError { /** HTTP status code, when one could be extracted from the SDK error object. */ status?: number; /** Raw HTTP body reason, already trimmed and truncated to the cap. */ body?: string; /** `error.message`, or `safeJsonStringify(error)` for a non-`Error` throw. */ message: string; /** True when `message` already contains the body (no separate body to add). */ messageCarriesBody: boolean; } type SdkErrorShape = Error & { statusCode?: unknown; status?: unknown; body?: unknown; error?: unknown; $metadata?: { httpStatusCode?: unknown }; $response?: { statusCode?: unknown; body?: unknown }; }; export function normalizeProviderError(error: unknown): NormalizedProviderError { if (!(error instanceof Error)) { return { message: safeJsonStringify(error), messageCarriesBody: false }; } const sdkError = error as SdkErrorShape; const status = extractStatus(sdkError); const body = extractBody(sdkError); const messageCarriesBody = body === undefined || error.message.includes(body); return { status, body, message: error.message, messageCarriesBody, } satisfies NormalizedProviderError; } /** * Probe the HTTP status, first numeric hit wins, in SDK-field order: * `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) → * `$metadata.httpStatusCode` (Bedrock) → `$response.statusCode` (Bedrock). */ function extractStatus(error: SdkErrorShape): number | undefined { if (typeof error.statusCode === "number") return error.statusCode; if (typeof error.status === "number") return error.status; if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode; if (typeof error.$response?.statusCode === "number") return error.$response.statusCode; return undefined; } /** * Probe the raw body reason, first usable hit wins, in SDK-field order: * `body` string (Mistral) → `error` parsed JSON body object (`openai` SDK's * `this.error`) → `$response.body` (Bedrock). Empty objects and unread response * streams are treated as no body so they do not surface as `"{}"` or serialized * stream internals. The chosen body is truncated to the cap. */ function extractBody(error: SdkErrorShape): string | undefined { const bodyText = pickBodyText(error); if (bodyText === undefined) return undefined; const trimmed = bodyText.trim(); if (trimmed.length === 0) return undefined; return truncateErrorText(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS); } function pickBodyText(error: SdkErrorShape): string | undefined { if (typeof error.body === "string") return error.body; if (isPlainNonEmptyObject(error.error)) return safeJsonStringify(error.error); const responseBody = error.$response?.body; if (typeof responseBody === "string") return responseBody; if (isReadableStreamLike(responseBody)) return undefined; if (isPlainNonEmptyObject(responseBody)) return safeJsonStringify(responseBody); return undefined; } function isReadableStreamLike(value: unknown): boolean { return typeof value === "object" && value !== null && "pipe" in value && typeof value.pipe === "function"; } /** * Only a PLAIN object counts as an HTTP body. SDK error fields can hold class * instances instead of parsed bodies — AWS SDK v3's `$response.body` is an * HTTP stream/response wrapper object, and stringifying one produced garbage * like `{"_events":...}` as the "body", which then REPLACED `error.message` * in the composed display string. `error.message` is where the SDK puts the * real deserialized exception text ("Input is too long...", schema validation * details, ...), so the one useful string was discarded for noise. A class * instance yields no body, `messageCarriesBody` stays true, and the real * message survives. Complements the `pipe` sniffing above: web * ReadableStreams (pipeTo/pipeThrough, no `pipe`) and non-stream SDK wrapper * classes fail the prototype check, while parsed JSON bodies (plain objects * by construction) still pass. */ function isPlainNonEmptyObject(value: unknown): boolean { if (typeof value !== "object" || value === null) return false; const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) return false; return Object.keys(value).length > 0; } /** * Compose a display string from a normalized error. When the message already * carries the body (Anthropic / `@google/genai` happy path) or no body/status * was extracted, the message is returned unchanged. Otherwise the status and * body are surfaced, with an optional provider prefix. * * - no prefix: `": "` * - prefix: `" (): "` */ export function formatProviderError(norm: NormalizedProviderError, prefix?: string): string { if (norm.messageCarriesBody || norm.status === undefined || norm.body === undefined) { return prefix !== undefined && norm.status !== undefined ? `${prefix} (${norm.status}): ${norm.message}` : norm.message; } return prefix !== undefined ? `${prefix} (${norm.status}): ${norm.body}` : `${norm.status}: ${norm.body}`; } export function truncateErrorText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; } export function safeJsonStringify(value: unknown): string { try { const serialized = JSON.stringify(value); return serialized === undefined ? String(value) : serialized; } catch { return String(value); } }