Fix Bedrock blank text blocks

closes #4975
This commit is contained in:
Mario Zechner
2026-06-01 18:03:49 +02:00
parent 893ba99466
commit 34719d3f4f
4 changed files with 187 additions and 60 deletions

View File

@@ -18,6 +18,7 @@ import {
type SystemContentBlock,
type ToolChoice,
type ToolConfiguration,
type ToolResultContentBlock,
ToolResultStatus,
} from "@aws-sdk/client-bedrock-runtime";
import { NodeHttpHandler } from "@smithy/node-http-handler";
@@ -28,6 +29,7 @@ import type {
AssistantMessage,
CacheRetention,
Context,
ImageContent,
Model,
SimpleStreamOptions,
StopReason,
@@ -86,6 +88,8 @@ export interface BedrockOptions extends StreamOptions {
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
model: Model<"bedrock-converse-stream">,
context: Context,
@@ -650,6 +654,29 @@ function normalizeToolCallId(id: string): string {
return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
}
function createNonBlankTextBlock(text: string): ContentBlock.TextMember | undefined {
const sanitized = sanitizeSurrogates(text);
return sanitized.trim().length === 0 ? undefined : { text: sanitized };
}
function createRequiredTextBlock(text: string): ContentBlock.TextMember {
return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER };
}
function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] {
const result: ToolResultContentBlock[] = [];
for (const c of content) {
if (c.type === "image") {
result.push({ image: createImageBlock(c.mimeType, c.data) });
} else {
const textBlock = createNonBlankTextBlock(c.text);
if (textBlock) result.push(textBlock);
}
}
if (result.length === 0) result.push({ text: EMPTY_TEXT_PLACEHOLDER });
return result;
}
function convertMessages(
context: Context,
model: Model<"bedrock-converse-stream">,
@@ -665,13 +692,15 @@ function convertMessages(
case "user": {
const content: ContentBlock[] = [];
if (typeof m.content === "string") {
content.push({ text: sanitizeSurrogates(m.content) });
content.push(createRequiredTextBlock(m.content));
} else {
for (const c of m.content) {
switch (c.type) {
case "text":
content.push({ text: sanitizeSurrogates(c.text) });
case "text": {
const textBlock = createNonBlankTextBlock(c.text);
if (textBlock) content.push(textBlock);
break;
}
case "image":
content.push({ image: createImageBlock(c.mimeType, c.data) });
break;
@@ -679,8 +708,8 @@ function convertMessages(
continue;
}
}
if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER });
}
if (content.length === 0) continue;
result.push({
role: ConversationRole.USER,
content,
@@ -696,19 +725,22 @@ function convertMessages(
const contentBlocks: ContentBlock[] = [];
for (const c of m.content) {
switch (c.type) {
case "text":
case "text": {
// Skip empty text blocks
if (c.text.trim().length === 0) continue;
contentBlocks.push({ text: sanitizeSurrogates(c.text) });
const textBlock = createNonBlankTextBlock(c.text);
if (!textBlock) continue;
contentBlocks.push(textBlock);
break;
}
case "toolCall":
contentBlocks.push({
toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
});
break;
case "thinking":
case "thinking": {
// Skip empty thinking blocks
if (c.thinking.trim().length === 0) continue;
const thinking = sanitizeSurrogates(c.thinking);
if (thinking.trim().length === 0) continue;
// Only Anthropic models support the signature field in reasoningText.
// For other models, we omit the signature to avoid errors like:
// "This model doesn't support the reasoningContent.reasoningText.signature field"
@@ -717,12 +749,12 @@ function convertMessages(
// persisted message lacks a signature, Bedrock rejects the replayed
// reasoning block. Fall back to plain text, matching Anthropic.
if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) {
contentBlocks.push({ text: sanitizeSurrogates(c.thinking) });
contentBlocks.push({ text: thinking });
} else {
contentBlocks.push({
reasoningContent: {
reasoningText: {
text: sanitizeSurrogates(c.thinking),
text: thinking,
signature: c.thinkingSignature,
},
},
@@ -731,11 +763,12 @@ function convertMessages(
} else {
contentBlocks.push({
reasoningContent: {
reasoningText: { text: sanitizeSurrogates(c.thinking) },
reasoningText: { text: thinking },
},
});
}
break;
}
default:
continue;
}
@@ -759,11 +792,7 @@ function convertMessages(
toolResults.push({
toolResult: {
toolUseId: m.toolCallId,
content: m.content.map((c) =>
c.type === "image"
? { image: createImageBlock(c.mimeType, c.data) }
: { text: sanitizeSurrogates(c.text) },
),
content: convertToolResultContent(m.content),
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
},
});
@@ -775,11 +804,7 @@ function convertMessages(
toolResults.push({
toolResult: {
toolUseId: nextMsg.toolCallId,
content: nextMsg.content.map((c) =>
c.type === "image"
? { image: createImageBlock(c.mimeType, c.data) }
: { text: sanitizeSurrogates(c.text) },
),
content: convertToolResultContent(nextMsg.content),
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
},
});