diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index 80e641d94d..2eb678f33c 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -179,4 +179,301 @@ describe("convertToR1Format", () => { expect(convertToR1Format(input)).toEqual(expected) }) + + describe("tool message handling", () => { + it("should convert tool_use blocks to OpenAI tool_calls format", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file test.txt" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll read that file for you." }, + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ role: "user", content: "Read the file test.txt" }) + + const assistantMsg = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMsg.role).toBe("assistant") + expect(assistantMsg.content).toBe("I'll read that file for you.") + expect(assistantMsg.tool_calls).toHaveLength(1) + expect(assistantMsg.tool_calls![0]).toEqual({ + id: "tool-123", + type: "function", + function: { + name: "read_file", + arguments: '{"path":"test.txt"}', + }, + }) + }) + + it("should convert tool_result blocks to OpenAI tool messages", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents: Hello World", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "tool-123", + content: "File contents: Hello World", + }) + }) + + it("should handle tool_result with array content", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-456", + content: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ], + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "tool-456", + content: "Line 1\nLine 2", + }) + }) + + it("should handle multiple tool_use blocks in one message", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "I'll read both files." }, + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "file1.txt" }, + }, + { + type: "tool_use", + id: "tool-2", + name: "read_file", + input: { path: "file2.txt" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMsg.tool_calls).toHaveLength(2) + expect(assistantMsg.tool_calls![0].id).toBe("tool-1") + expect(assistantMsg.tool_calls![1].id).toBe("tool-2") + }) + + it("should handle multiple tool_result blocks in one message", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Contents of file1", + }, + { + type: "tool_result", + tool_use_id: "tool-2", + content: "Contents of file2", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "tool-1", + content: "Contents of file1", + }) + expect(result[1]).toEqual({ + role: "tool", + tool_call_id: "tool-2", + content: "Contents of file2", + }) + }) + + it("should handle full conversation with tool calls and results", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "What's in test.txt?" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check that file." }, + { + type: "tool_use", + id: "tool-abc", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-abc", + content: "Hello, World!", + }, + ], + }, + { + role: "assistant", + content: "The file test.txt contains: Hello, World!", + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(4) + + // User message + expect(result[0]).toEqual({ role: "user", content: "What's in test.txt?" }) + + // Assistant with tool call + const assistantMsg = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMsg.role).toBe("assistant") + expect(assistantMsg.content).toBe("Let me check that file.") + expect(assistantMsg.tool_calls).toHaveLength(1) + expect(assistantMsg.tool_calls![0].id).toBe("tool-abc") + + // Tool result + expect(result[2]).toEqual({ + role: "tool", + tool_call_id: "tool-abc", + content: "Hello, World!", + }) + + // Final assistant response + expect(result[3]).toEqual({ + role: "assistant", + content: "The file test.txt contains: Hello, World!", + }) + }) + + it("should handle tool_result mixed with text content", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool output here", + }, + { type: "text", text: "Also, here is some additional context." }, + ], + }, + ] + + const result = convertToR1Format(input) + + // Tool results should come first, then user text + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "tool-123", + content: "Tool output here", + }) + expect(result[1]).toEqual({ + role: "user", + content: "Also, here is some additional context.", + }) + }) + + it("should not merge assistant messages after tool_calls", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "test_tool", + input: {}, + }, + ], + }, + { + role: "assistant", + content: "Follow-up text", + }, + ] + + const result = convertToR1Format(input) + + // Should NOT merge because first message has tool_calls + expect(result).toHaveLength(2) + expect((result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toBeDefined() + expect(result[1]).toEqual({ + role: "assistant", + content: "Follow-up text", + }) + }) + + it("should handle tool_use without text content", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-only", + name: "some_tool", + input: { key: "value" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMsg.role).toBe("assistant") + expect(assistantMsg.content).toBeNull() + expect(assistantMsg.tool_calls).toHaveLength(1) + }) + }) }) diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index 51a4b94dbc..bfb685781b 100644 --- a/src/api/transform/r1-format.ts +++ b/src/api/transform/r1-format.ts @@ -5,6 +5,7 @@ type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam +type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam type Message = OpenAI.Chat.ChatCompletionMessageParam type AnthropicMessage = Anthropic.Messages.MessageParam @@ -12,87 +13,210 @@ type AnthropicMessage = Anthropic.Messages.MessageParam * Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role. * This is required for DeepSeek Reasoner which does not support successive messages with the same role. * + * Also handles tool_use and tool_result blocks: + * - tool_use blocks in assistant messages are converted to tool_calls + * - tool_result blocks in user messages are converted to tool role messages + * * @param messages Array of Anthropic messages * @returns Array of OpenAI messages where consecutive messages with the same role are combined */ export function convertToR1Format(messages: AnthropicMessage[]): Message[] { - return messages.reduce((merged, message) => { - const lastMessage = merged[merged.length - 1] - let messageContent: string | (ContentPartText | ContentPartImage)[] = "" - let hasImages = false + const result: Message[] = [] - // Convert content to appropriate format - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] + for (const message of messages) { + if (typeof message.content === "string") { + // Simple string content - can be merged with previous same-role message + appendOrMergeMessage(result, message.role, message.content) + } else { + // Array content - need to process each block + if (message.role === "user") { + processUserMessage(result, message.content) + } else if (message.role === "assistant") { + processAssistantMessage(result, message.content) + } + } + } - message.content.forEach((part) => { + return result +} + +/** + * Process user message content blocks, handling tool_result blocks separately + */ +function processUserMessage(result: Message[], content: Anthropic.Messages.ContentBlockParam[]): void { + const textParts: string[] = [] + const imageParts: ContentPartImage[] = [] + const toolResults: Anthropic.ToolResultBlockParam[] = [] + let hasImages = false + + // Separate tool results from other content + for (const part of content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "image") { + hasImages = true + imageParts.push({ + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + }) + } else if (part.type === "tool_result") { + toolResults.push(part) + } + } + + // First, add tool result messages (they must come right after assistant tool_calls) + for (const toolResult of toolResults) { + const toolContent = extractToolResultContent(toolResult) + const toolMessage: ToolMessage = { + role: "tool", + tool_call_id: toolResult.tool_use_id, + content: toolContent, + } + result.push(toolMessage) + } + + // Then add non-tool content as user message + if (textParts.length > 0 || imageParts.length > 0) { + let messageContent: string | (ContentPartText | ContentPartImage)[] + + if (hasImages) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + messageContent = parts + } else { + messageContent = textParts.join("\n") + } + + appendOrMergeMessage(result, "user", messageContent) + } +} + +/** + * Process assistant message content blocks, handling tool_use blocks + */ +function processAssistantMessage(result: Message[], content: Anthropic.Messages.ContentBlockParam[]): void { + const textParts: string[] = [] + const toolUses: Anthropic.ToolUseBlockParam[] = [] + + // Separate tool uses from text content + for (const part of content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "tool_use") { + toolUses.push(part) + } + // Images from assistant are ignored (not possible in practice) + } + + const textContent = textParts.length > 0 ? textParts.join("\n") : undefined + + if (toolUses.length > 0) { + // If there are tool uses, create a new assistant message with tool_calls + // Tool calls cannot be merged with previous messages + const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolUses.map((toolUse) => ({ + id: toolUse.id, + type: "function" as const, + function: { + name: toolUse.name, + arguments: JSON.stringify(toolUse.input), + }, + })) + + const assistantMessage: AssistantMessage = { + role: "assistant", + content: textContent ?? null, + tool_calls: toolCalls, + } + result.push(assistantMessage) + } else if (textContent) { + // No tool uses - can merge with previous assistant message + appendOrMergeMessage(result, "assistant", textContent) + } +} + +/** + * Extract content string from a tool result block + */ +function extractToolResultContent(toolResult: Anthropic.ToolResultBlockParam): string { + if (typeof toolResult.content === "string") { + return toolResult.content + } + + if (Array.isArray(toolResult.content)) { + return toolResult.content + .map((part) => { if (part.type === "text") { - textParts.push(part.text) + return part.text } if (part.type === "image") { - hasImages = true - imageParts.push({ - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }) + return "(image content)" } + return "" }) + .filter(Boolean) + .join("\n") + } - if (hasImages) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) - } - parts.push(...imageParts) - messageContent = parts - } else { - messageContent = textParts.join("\n") - } - } else { - messageContent = message.content - } - - // If last message has same role, merge the content - if (lastMessage?.role === message.role) { - if (typeof lastMessage.content === "string" && typeof messageContent === "string") { - lastMessage.content += `\n${messageContent}` - } - // If either has image content, convert both to array format - else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - - const newContent = Array.isArray(messageContent) - ? messageContent - : [{ type: "text" as const, text: messageContent }] - - if (message.role === "assistant") { - const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"] - lastMessage.content = mergedContent - } else { - const mergedContent = [...lastContent, ...newContent] as UserMessage["content"] - lastMessage.content = mergedContent - } - } - } else { - // Add as new message with the correct type based on role - if (message.role === "assistant") { - const newMessage: AssistantMessage = { - role: "assistant", - content: messageContent as AssistantMessage["content"], - } - merged.push(newMessage) - } else { - const newMessage: UserMessage = { - role: "user", - content: messageContent as UserMessage["content"], - } - merged.push(newMessage) - } - } - - return merged - }, []) + return "" +} + +/** + * Append a message to the result array, merging with the previous message if it has the same role + * and neither is a tool message + */ +function appendOrMergeMessage( + result: Message[], + role: "user" | "assistant", + content: string | (ContentPartText | ContentPartImage)[], +): void { + const lastMessage = result[result.length - 1] + + // Can only merge if: + // 1. Last message exists and has the same role + // 2. Last message is not a tool message + // 3. Last message doesn't have tool_calls (for assistant messages) + if ( + lastMessage && + lastMessage.role === role && + !("tool_call_id" in lastMessage) && + !("tool_calls" in lastMessage && lastMessage.tool_calls) + ) { + // Merge content + if (typeof lastMessage.content === "string" && typeof content === "string") { + lastMessage.content += `\n${content}` + } else { + // Convert both to array format and merge + const lastContent = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text" as const, text: lastMessage.content || "" }] + + const newContent = Array.isArray(content) ? content : [{ type: "text" as const, text: content }] + + if (role === "assistant") { + const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"] + lastMessage.content = mergedContent + } else { + const mergedContent = [...lastContent, ...newContent] as UserMessage["content"] + lastMessage.content = mergedContent + } + } + } else { + // Add as new message + if (role === "assistant") { + const newMessage: AssistantMessage = { + role: "assistant", + content: content as AssistantMessage["content"], + } + result.push(newMessage) + } else { + const newMessage: UserMessage = { + role: "user", + content: content as UserMessage["content"], + } + result.push(newMessage) + } + } }