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

@@ -4,6 +4,7 @@
### Fixed
- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)).
- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)).
## [0.78.0] - 2026-05-29

View File

@@ -394,8 +394,8 @@ export const MODELS = {
thinkingLevelMap: {"xhigh":"max"},
input: ["text", "image"],
cost: {
input: 5,
output: 25,
input: 5.5,
output: 27.5,
cacheRead: 0.5,
cacheWrite: 6.25,
},
@@ -412,10 +412,10 @@ export const MODELS = {
thinkingLevelMap: {"xhigh":"xhigh"},
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
input: 5.5,
output: 27.5,
cacheRead: 0.55,
cacheWrite: 6.875,
},
contextWindow: 1000000,
maxTokens: 128000,
@@ -430,10 +430,10 @@ export const MODELS = {
thinkingLevelMap: {"xhigh":"xhigh"},
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
input: 5.5,
output: 27.5,
cacheRead: 0.55,
cacheWrite: 6.875,
},
contextWindow: 1000000,
maxTokens: 128000,
@@ -447,10 +447,10 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
input: 3.3,
output: 16.5,
cacheRead: 0.33,
cacheWrite: 4.125,
},
contextWindow: 200000,
maxTokens: 64000,
@@ -464,10 +464,10 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
input: 3.3,
output: 16.5,
cacheRead: 0.33,
cacheWrite: 4.125,
},
contextWindow: 1000000,
maxTokens: 64000,
@@ -2990,25 +2990,6 @@ export const MODELS = {
contextWindow: 1000000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"claude-opus-4-8": {
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic",
compat: {"forceAdaptiveThinking":true},
reasoning: true,
thinkingLevelMap: {"xhigh":"xhigh"},
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
},
contextWindow: 1000000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"claude-sonnet-4": {
id: "claude-sonnet-4",
name: "Claude Sonnet 4 (latest)",
@@ -12066,6 +12047,23 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 32768,
} satisfies Model<"openai-completions">,
"upstage/solar-pro-3": {
id: "upstage/solar-pro-3",
name: "Upstage: Solar Pro 3",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0.15,
output: 0.6,
cacheRead: 0.015,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"x-ai/grok-4.20": {
id: "x-ai/grok-4.20",
name: "xAI: Grok 4.20",
@@ -15131,6 +15129,23 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 8000,
} satisfies Model<"anthropic-messages">,
"stepfun/step-3.5-flash": {
id: "stepfun/step-3.5-flash",
name: "StepFun 3.5 Flash",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 0.09,
output: 0.3,
cacheRead: 0,
cacheWrite: 0.02,
},
contextWindow: 262114,
maxTokens: 262114,
} satisfies Model<"anthropic-messages">,
"stepfun/step-3.7-flash": {
id: "stepfun/step-3.7-flash",
name: "Step 3.7 Flash",

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,
},
});

View File

@@ -117,7 +117,7 @@ describe("bedrock convertMessages skips unknown content types", () => {
expect(p.messages[0].content[0]).toEqual({ text: "hello" });
});
it("skips user messages with only unknown content blocks", async () => {
it("replaces user messages with only unknown content blocks with a placeholder", async () => {
const messages: Message[] = [
{
role: "user",
@@ -128,9 +128,95 @@ describe("bedrock convertMessages skips unknown content types", () => {
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).toEqual([{ text: "<empty>" }]);
});
it("replaces blank user string content with a placeholder", async () => {
const payload = await capturePayload({
messages: [{ role: "user", content: " ", timestamp: Date.now() }],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("filters blank user text blocks when other content remains", async () => {
const payload = await capturePayload({
messages: [
{
role: "user",
content: [
{ type: "text", text: "" },
{ type: "text", text: "hello" },
],
timestamp: Date.now(),
},
],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "hello" }]);
});
it("replaces user content emptied by surrogate sanitization with a placeholder", async () => {
const payload = await capturePayload({
messages: [{ role: "user", content: String.fromCharCode(0xd83d), timestamp: Date.now() }],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("skips assistant text blocks emptied by surrogate sanitization", async () => {
const messages: Message[] = [
{
role: "assistant",
content: [{ type: "text", text: String.fromCharCode(0xd83d) }],
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);
});
it("replaces blank tool result content with a placeholder", async () => {
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "tool",
content: [{ type: "text", text: "" }],
isError: false,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as {
messages: Array<{ role: string; content: Array<{ toolResult: { content: unknown[] } }> }>;
};
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "<empty>" }]);
});
it("skips assistant messages with only unknown content blocks", async () => {
const messages: Message[] = [
{