@earendil-works/pi-ai
Version:
Unified LLM API with automatic model discovery and provider configuration
161 lines • 7.04 kB
JavaScript
// 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.
import { ownedSlice } from "./text.js";
export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000;
export function normalizeProviderError(error) {
if (!(error instanceof Error)) {
return { message: safeJsonStringify(error), messageCarriesBody: false };
}
const sdkError = error;
const status = extractStatus(sdkError);
const body = extractBody(sdkError);
const messageCarriesBody = body === undefined || error.message.includes(body);
const message = status === undefined && body === undefined ? withCauseDetail(error) : error.message;
return {
status,
body,
message,
messageCarriesBody,
};
}
const MAX_CAUSE_DEPTH = 5;
/**
* Connection-level failures carry no status or body, and SDKs replace the
* reason with a generic message (`openai`: `"Connection error."`). The real
* reason sits in the `cause` chain, often two levels deep (undici's
* `"fetch failed"` wraps `connect ECONNREFUSED ...`). Append the deepest
* cause so callers that surface only the message keep the reason.
*/
export function withCauseDetail(error) {
let deepest;
let current = error.cause;
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
deepest = current;
current = current.cause;
}
if (!deepest)
return error.message;
let detail = (deepest.message || deepest.name).trim();
const code = deepest.code;
if ((typeof code === "string" || typeof code === "number") && !detail.includes(String(code))) {
detail = detail ? `${detail} (${code})` : String(code);
}
if (!detail || error.message.includes(detail))
return error.message;
return error.message ? `${error.message}: ${detail}` : detail;
}
/**
* 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) {
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) {
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) {
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) {
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) {
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: `"<status>: <body>"`
* - prefix: `"<prefix> (<status>): <body>"`
*/
export function formatProviderError(norm, prefix) {
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, maxChars) {
if (text.length <= maxChars)
return text;
return `${ownedSlice(text, 0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
export function safeJsonStringify(value) {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
}
catch {
return String(value);
}
}
//# sourceMappingURL=error-body.js.map