diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 57a25241..98922700 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c5bd76e5..e1a2facc 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -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", diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index b5c88bc5..0de0ea6e 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -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 = ""; + 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, }, }); diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts index d62abd1b..d74dedae 100644 --- a/packages/ai/test/bedrock-convert-messages.test.ts +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -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: "" }]); + }); + + 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: "" }]); + }); + + 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: "" }]); + }); + + 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: "" }]); + }); + it("skips assistant messages with only unknown content blocks", async () => { const messages: Message[] = [ {