diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts index d49e09064..cb8c1859a 100644 --- a/packages/ai/src/utils/error-body.ts +++ b/packages/ai/src/utils/error-body.ts @@ -83,11 +83,11 @@ function extractBody(error: SdkErrorShape): string | undefined { function pickBodyText(error: SdkErrorShape): string | undefined { if (typeof error.body === "string") return error.body; - if (isNonEmptyObject(error.error)) return safeJsonStringify(error.error); + 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 (isNonEmptyObject(responseBody)) return safeJsonStringify(responseBody); + if (isPlainNonEmptyObject(responseBody)) return safeJsonStringify(responseBody); return undefined; } @@ -95,8 +95,25 @@ function isReadableStreamLike(value: unknown): boolean { return typeof value === "object" && value !== null && "pipe" in value && typeof value.pipe === "function"; } -function isNonEmptyObject(value: unknown): boolean { - return typeof value === "object" && value !== null && Object.keys(value).length > 0; +/** + * 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; } /** diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts index 1ba80623f..15cf5e11c 100644 --- a/packages/ai/test/error-body.test.ts +++ b/packages/ai/test/error-body.test.ts @@ -85,6 +85,57 @@ describe("normalizeProviderError", () => { expect(norm.messageCarriesBody).toBe(true); }); + it("ignores a class-instance response body without a pipe method instead of serializing it", () => { + // Not every SDK response wrapper is a node stream: web ReadableStreams + // and SDK-specific wrapper classes have no `pipe`, but serializing them + // still yields internals-noise that would replace the real message. + class SdkHttpResponseBody { + locked = false; + state = { storedError: undefined }; + } + const error = Object.assign(new Error("Input is too long for requested model."), { + name: "ValidationException", + $metadata: { httpStatusCode: 400 }, + $response: { statusCode: 400, body: new SdkHttpResponseBody() }, + }); + + const norm = normalizeProviderError(error); + + expect(norm.status).toBe(400); + expect(norm.body).toBeUndefined(); + expect(norm.message).toContain("Input is too long"); + expect(norm.messageCarriesBody).toBe(true); + }); + + it("ignores a class-instance `error` field instead of serializing it", () => { + class SdkInnerError { + code = "EPROTO"; + internalState = {}; + } + const error = Object.assign(new Error("TLS handshake failed"), { + status: 502, + error: new SdkInnerError(), + }); + + const norm = normalizeProviderError(error); + + expect(norm.body).toBeUndefined(); + expect(norm.message).toBe("TLS handshake failed"); + expect(norm.messageCarriesBody).toBe(true); + }); + + it("still surfaces a plain parsed JSON body object", () => { + const error = Object.assign(new Error("400 status code (no body)"), { + status: 400, + error: { message: "schema validation failed", field: "tools[0]" }, + }); + + const norm = normalizeProviderError(error); + + expect(norm.body).toBe('{"message":"schema validation failed","field":"tools[0]"}'); + expect(norm.messageCarriesBody).toBe(false); + }); + it("JSON-stringifies a non-Error thrown value", () => { const norm = normalizeProviderError({ reason: "boom" });