Просмотр исходного кода

fix: truncate AWS Bedrock toolUseId to 64 characters (#10902)

Daniel 2 недель назад
Родитель
Сommit
f9a3a178db

+ 215 - 0
src/api/transform/__tests__/bedrock-converse-format.spec.ts

@@ -3,6 +3,7 @@
 import { convertToBedrockConverseMessages } from "../bedrock-converse-format"
 import { Anthropic } from "@anthropic-ai/sdk"
 import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime"
+import { OPENAI_CALL_ID_MAX_LENGTH } from "../../../utils/tool-id"
 
 describe("convertToBedrockConverseMessages", () => {
 	it("converts simple text messages correctly", () => {
@@ -341,4 +342,218 @@ describe("convertToBedrockConverseMessages", () => {
 		const textBlock = result[0].content[0] as ContentBlock
 		expect(textBlock).toEqual({ text: "Hello world" })
 	})
+
+	describe("toolUseId sanitization for Bedrock 64-char limit", () => {
+		it("truncates toolUseId longer than 64 characters in tool_use blocks", () => {
+			const longId = "a".repeat(100)
+			const messages: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: longId,
+							name: "read_file",
+							input: { path: "test.txt" },
+						},
+					],
+				},
+			]
+
+			const result = convertToBedrockConverseMessages(messages)
+			const toolBlock = result[0]?.content?.[0] as ContentBlock
+
+			if ("toolUse" in toolBlock && toolBlock.toolUse && toolBlock.toolUse.toolUseId) {
+				expect(toolBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+				expect(toolBlock.toolUse.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
+				expect(toolBlock.toolUse.toolUseId).toContain("_")
+			} else {
+				expect.fail("Expected tool use block not found")
+			}
+		})
+
+		it("truncates toolUseId longer than 64 characters in tool_result blocks with string content", () => {
+			const longId = "b".repeat(100)
+			const messages: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "user",
+					content: [
+						{
+							type: "tool_result",
+							tool_use_id: longId,
+							content: "Result content",
+						} as any,
+					],
+				},
+			]
+
+			const result = convertToBedrockConverseMessages(messages)
+			const resultBlock = result[0]?.content?.[0] as ContentBlock
+
+			if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) {
+				expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+				expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
+				expect(resultBlock.toolResult.toolUseId).toContain("_")
+			} else {
+				expect.fail("Expected tool result block not found")
+			}
+		})
+
+		it("truncates toolUseId longer than 64 characters in tool_result blocks with array content", () => {
+			const longId = "c".repeat(100)
+			const messages: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "user",
+					content: [
+						{
+							type: "tool_result",
+							tool_use_id: longId,
+							content: [{ type: "text", text: "Result content" }],
+						},
+					],
+				},
+			]
+
+			const result = convertToBedrockConverseMessages(messages)
+			const resultBlock = result[0]?.content?.[0] as ContentBlock
+
+			if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) {
+				expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+				expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
+			} else {
+				expect.fail("Expected tool result block not found")
+			}
+		})
+
+		it("keeps toolUseId unchanged when under 64 characters", () => {
+			const shortId = "short-id-123"
+			const messages: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: shortId,
+							name: "read_file",
+							input: { path: "test.txt" },
+						},
+					],
+				},
+			]
+
+			const result = convertToBedrockConverseMessages(messages)
+			const toolBlock = result[0]?.content?.[0] as ContentBlock
+
+			if ("toolUse" in toolBlock && toolBlock.toolUse) {
+				expect(toolBlock.toolUse.toolUseId).toBe(shortId)
+			} else {
+				expect.fail("Expected tool use block not found")
+			}
+		})
+
+		it("produces consistent truncated IDs for the same input", () => {
+			const longId = "d".repeat(100)
+			const messages: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: longId,
+							name: "read_file",
+							input: { path: "test.txt" },
+						},
+					],
+				},
+			]
+
+			const result1 = convertToBedrockConverseMessages(messages)
+			const result2 = convertToBedrockConverseMessages(messages)
+
+			const toolBlock1 = result1[0]?.content?.[0] as ContentBlock
+			const toolBlock2 = result2[0]?.content?.[0] as ContentBlock
+
+			if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) {
+				expect(toolBlock1.toolUse.toolUseId).toBe(toolBlock2.toolUse.toolUseId)
+			} else {
+				expect.fail("Expected tool use blocks not found")
+			}
+		})
+
+		it("produces different truncated IDs for different long inputs", () => {
+			const longId1 = "e".repeat(100)
+			const longId2 = "f".repeat(100)
+
+			const messages1: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "assistant",
+					content: [{ type: "tool_use", id: longId1, name: "read_file", input: {} }],
+				},
+			]
+			const messages2: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "assistant",
+					content: [{ type: "tool_use", id: longId2, name: "read_file", input: {} }],
+				},
+			]
+
+			const result1 = convertToBedrockConverseMessages(messages1)
+			const result2 = convertToBedrockConverseMessages(messages2)
+
+			const toolBlock1 = result1[0]?.content?.[0] as ContentBlock
+			const toolBlock2 = result2[0]?.content?.[0] as ContentBlock
+
+			if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) {
+				expect(toolBlock1.toolUse.toolUseId).not.toBe(toolBlock2.toolUse.toolUseId)
+			} else {
+				expect.fail("Expected tool use blocks not found")
+			}
+		})
+
+		it("matching tool_use and tool_result IDs are both truncated consistently", () => {
+			const longId = "g".repeat(100)
+			const messages: Anthropic.Messages.MessageParam[] = [
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: longId,
+							name: "read_file",
+							input: { path: "test.txt" },
+						},
+					],
+				},
+				{
+					role: "user",
+					content: [
+						{
+							type: "tool_result",
+							tool_use_id: longId,
+							content: "File contents",
+						} as any,
+					],
+				},
+			]
+
+			const result = convertToBedrockConverseMessages(messages)
+
+			const toolUseBlock = result[0]?.content?.[0] as ContentBlock
+			const toolResultBlock = result[1]?.content?.[0] as ContentBlock
+
+			if (
+				"toolUse" in toolUseBlock &&
+				toolUseBlock.toolUse &&
+				toolUseBlock.toolUse.toolUseId &&
+				"toolResult" in toolResultBlock &&
+				toolResultBlock.toolResult &&
+				toolResultBlock.toolResult.toolUseId
+			) {
+				expect(toolUseBlock.toolUse.toolUseId).toBe(toolResultBlock.toolResult.toolUseId)
+				expect(toolUseBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+			} else {
+				expect.fail("Expected tool use and result blocks not found")
+			}
+		})
+	})
 })

+ 7 - 6
src/api/transform/bedrock-converse-format.ts

@@ -1,5 +1,6 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime"
+import { sanitizeOpenAiCallId } from "../../utils/tool-id"
 
 interface BedrockMessageContent {
 	type: "text" | "image" | "video" | "tool_use" | "tool_result"
@@ -90,7 +91,7 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 				// Native-only: keep input as JSON object for Bedrock's toolUse format
 				return {
 					toolUse: {
-						toolUseId: messageBlock.id || "",
+						toolUseId: sanitizeOpenAiCallId(messageBlock.id || ""),
 						name: messageBlock.name || "",
 						input: messageBlock.input || {},
 					},
@@ -104,7 +105,7 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 					if (typeof messageBlock.content === "string") {
 						return {
 							toolResult: {
-								toolUseId: messageBlock.tool_use_id || "",
+								toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
 								content: [
 									{
 										text: messageBlock.content,
@@ -118,7 +119,7 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 					if (Array.isArray(messageBlock.content)) {
 						return {
 							toolResult: {
-								toolUseId: messageBlock.tool_use_id || "",
+								toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
 								content: messageBlock.content.map((item) => ({
 									text: typeof item === "string" ? item : item.text || String(item),
 								})),
@@ -132,7 +133,7 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 				if (messageBlock.output && typeof messageBlock.output === "string") {
 					return {
 						toolResult: {
-							toolUseId: messageBlock.tool_use_id || "",
+							toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
 							content: [
 								{
 									text: messageBlock.output,
@@ -146,7 +147,7 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 				if (Array.isArray(messageBlock.output)) {
 					return {
 						toolResult: {
-							toolUseId: messageBlock.tool_use_id || "",
+							toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
 							content: messageBlock.output.map((part) => {
 								if (typeof part === "object" && "text" in part) {
 									return { text: part.text }
@@ -165,7 +166,7 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 				// Default case
 				return {
 					toolResult: {
-						toolUseId: messageBlock.tool_use_id || "",
+						toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
 						content: [
 							{
 								text: String(messageBlock.output || ""),