@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user