From 9e2bfc7c40072544e3d364fd76ab2535641398b7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 18 May 2026 01:41:58 +0200 Subject: [PATCH] fix(ai): skip unknown bedrock content blocks closes #4223 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/amazon-bedrock.ts | 39 +++-- .../ai/test/bedrock-convert-messages.test.ts | 159 ++++++++++++++++++ 3 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 packages/ai/test/bedrock-convert-messages.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cf957ad0..803bca78 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream ([#4223](https://github.com/earendil-works/pi/issues/4223)). - Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier ([#4232](https://github.com/earendil-works/pi/issues/4232)). - Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions ([#4505](https://github.com/earendil-works/pi/issues/4505)). - Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only ([#4251](https://github.com/earendil-works/pi/issues/4251)). diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 7c5ac164..a48b5897 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -618,24 +618,31 @@ function convertMessages( const m = transformedMessages[i]; switch (m.role) { - case "user": + case "user": { + const content: ContentBlock[] = []; + if (typeof m.content === "string") { + content.push({ text: sanitizeSurrogates(m.content) }); + } else { + for (const c of m.content) { + switch (c.type) { + case "text": + content.push({ text: sanitizeSurrogates(c.text) }); + break; + case "image": + content.push({ image: createImageBlock(c.mimeType, c.data) }); + break; + default: + continue; + } + } + } + if (content.length === 0) continue; result.push({ role: ConversationRole.USER, - content: - typeof m.content === "string" - ? [{ text: sanitizeSurrogates(m.content) }] - : m.content.map((c) => { - switch (c.type) { - case "text": - return { text: sanitizeSurrogates(c.text) }; - case "image": - return { image: createImageBlock(c.mimeType, c.data) }; - default: - throw new Error("Unknown user content type"); - } - }), + content, }); break; + } case "assistant": { // Skip assistant messages with empty content (e.g., from aborted requests) // Bedrock rejects messages with empty content arrays @@ -686,7 +693,7 @@ function convertMessages( } break; default: - throw new Error("Unknown assistant content type"); + continue; } } // Skip if all content blocks were filtered out @@ -745,7 +752,7 @@ function convertMessages( break; } default: - throw new Error("Unknown message role"); + continue; } } diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts new file mode 100644 index 00000000..d9608763 --- /dev/null +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; + +const bedrockMock = vi.hoisted(() => ({ + constructorCalls: [] as Array>, +})); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + constructor(config: Record) { + bedrockMock.constructorCalls.push(config); + } + + send(): Promise { + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { getModel } from "../src/models.js"; +import { streamBedrock } from "../src/providers/amazon-bedrock.js"; +import type { Context, Message } from "../src/types.js"; + +const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); + +async function capturePayload(context: Context): Promise { + let capturedPayload: unknown; + const s = streamBedrock(baseModel, context, { + cacheRetention: "none", + signal: AbortSignal.abort(), + onPayload: (payload) => { + capturedPayload = payload; + return payload; + }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + return capturedPayload; +} + +describe("bedrock convertMessages skips unknown content types", () => { + it("skips unknown user content blocks instead of throwing", async () => { + const messages: Message[] = [ + { + role: "user", + content: [ + { type: "text", text: "hello" }, + { type: "unknown", data: "foo" }, + ] as any, + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toHaveLength(1); + expect(p.messages[0].content[0]).toEqual({ text: "hello" }); + }); + + it("skips unknown assistant content blocks instead of throwing", async () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "hello" }, + { type: "unknown", data: "foo" }, + ] as any, + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toHaveLength(1); + expect(p.messages[0].content[0]).toEqual({ text: "hello" }); + }); + + it("skips user messages with only unknown content blocks", async () => { + const messages: Message[] = [ + { + role: "user", + content: [{ type: "unknown", data: "foo" }] as any, + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(0); + }); + + it("skips assistant messages with only unknown content blocks", async () => { + const messages: Message[] = [ + { + role: "assistant", + content: [{ type: "unknown", data: "foo" }] as any, + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(0); + }); +});