From 4523528b21c66084fdece7ed3ffa10888bc5ac60 Mon Sep 17 00:00:00 2001 From: Erik Ogenvik Date: Fri, 31 Jul 2026 09:38:54 +0200 Subject: [PATCH] fix(ai): treat only plain objects as provider error bodies (#7205) pickBodyText serialized any non-empty object into the error body. SDK error fields can hold class instances instead of parsed bodies - AWS SDK v3's $response.body is an HTTP stream/response wrapper - 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. The existing pipe-based stream sniffing only covers node-style streams; web ReadableStreams (pipeTo/pipeThrough, no pipe) and non-stream wrapper classes slipped through. Require Object.prototype/null prototype before serializing a candidate body - parsed JSON bodies are plain objects by construction, so real gateway bodies still surface. --- packages/ai/src/utils/error-body.ts | 25 +++++++++++--- packages/ai/test/error-body.test.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) 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" });