diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index 80e641d94d..d2e4444682 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -179,4 +179,327 @@ describe("convertToR1Format", () => { expect(convertToR1Format(input)).toEqual(expected) }) + + // Tool handling tests + describe("tool_use handling", () => { + it("should convert tool_use blocks to tool_calls in assistant messages", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll read the file for you." }, + { + type: "tool_use", + id: "toolu_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" }) + + const assistantMsg = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMsg.role).toBe("assistant") + expect(assistantMsg.content).toBe("I'll read the file for you.") + expect(assistantMsg.tool_calls).toEqual([ + { + id: "toolu_123", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "test.txt" }), + }, + }, + ]) + }) + + it("should convert multiple tool_use blocks to multiple tool_calls", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "read_file", + input: { path: "file1.txt" }, + }, + { + type: "tool_use", + id: "toolu_2", + name: "read_file", + input: { path: "file2.txt" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + + expect(assistantMsg.tool_calls).toHaveLength(2) + expect(assistantMsg.tool_calls![0].id).toBe("toolu_1") + expect(assistantMsg.tool_calls![1].id).toBe("toolu_2") + }) + + it("should handle assistant message with only tool_use (no text)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "update_todo_list", + input: { todos: "[ ] Task 1\n[ ] Task 2" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + + expect(assistantMsg.role).toBe("assistant") + expect(assistantMsg.content).toBeNull() + expect(assistantMsg.tool_calls).toHaveLength(1) + expect((assistantMsg.tool_calls![0] as any).function.name).toBe("update_todo_list") + }) + }) + + describe("tool_result handling", () => { + it("should convert tool_result blocks to tool messages", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: "File contents here", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(1) + const toolMsg = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMsg.role).toBe("tool") + expect(toolMsg.tool_call_id).toBe("toolu_123") + expect(toolMsg.content).toBe("File contents here") + }) + + it("should convert multiple tool_result blocks to multiple tool messages", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: "Result 1", + }, + { + type: "tool_result", + tool_use_id: "toolu_2", + content: "Result 2", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(2) + expect((result[0] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_1") + expect((result[1] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_2") + }) + + it("should handle tool_result with array content", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ], + }, + ], + }, + ] + + const result = convertToR1Format(input) + const toolMsg = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + + expect(toolMsg.content).toBe("Line 1\nLine 2") + }) + + it("should handle mixed tool_result and text content in user message", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: "Tool result", + }, + { + type: "text", + text: "User feedback", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + // Tool result should come first, then user message + expect(result).toHaveLength(2) + expect(result[0].role).toBe("tool") + expect((result[0] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_123") + expect(result[1].role).toBe("user") + expect((result[1] as OpenAI.Chat.ChatCompletionUserMessageParam).content).toBe("User feedback") + }) + }) + + describe("complete tool call flow", () => { + it("should handle a complete tool call cycle (request -> tool_use -> tool_result -> response)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Update my todo list" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll update your todo list." }, + { + type: "tool_use", + id: "toolu_update_123", + name: "update_todo_list", + input: { todos: "[ ] Task 1\n[x] Task 2" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_update_123", + content: "Todo list updated successfully.", + }, + ], + }, + { role: "assistant", content: "Your todo list has been updated!" }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(4) + + // First user message + expect(result[0]).toEqual({ role: "user", content: "Update my todo list" }) + + // Assistant with tool_calls + const assistantWithTool = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantWithTool.role).toBe("assistant") + expect(assistantWithTool.content).toBe("I'll update your todo list.") + expect(assistantWithTool.tool_calls).toHaveLength(1) + expect(assistantWithTool.tool_calls![0].id).toBe("toolu_update_123") + + // Tool result + const toolResult = result[2] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolResult.role).toBe("tool") + expect(toolResult.tool_call_id).toBe("toolu_update_123") + expect(toolResult.content).toBe("Todo list updated successfully.") + + // Final assistant response + expect(result[3]).toEqual({ + role: "assistant", + content: "Your todo list has been updated!", + }) + }) + + it("should not merge assistant messages when tool_calls are present", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + { role: "assistant", content: "Following up on that..." }, + ] + + const result = convertToR1Format(input) + + // Should have 2 separate messages, not merged + expect(result).toHaveLength(2) + expect((result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toBeDefined() + expect((result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toBeUndefined() + }) + + it("should handle multiple consecutive tool uses followed by results", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "read_file", + input: { path: "file1.txt" }, + }, + { + type: "tool_use", + id: "toolu_2", + name: "read_file", + input: { path: "file2.txt" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: "Content of file 1", + }, + { + type: "tool_result", + tool_use_id: "toolu_2", + content: "Content of file 2", + }, + ], + }, + ] + + const result = convertToR1Format(input) + + expect(result).toHaveLength(3) // 1 assistant + 2 tool messages + + const assistantMsg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMsg.tool_calls).toHaveLength(2) + + expect((result[1] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_1") + expect((result[2] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("toolu_2") + }) + }) }) diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index 51a4b94dbc..83511a32ca 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,257 @@ 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. * + * This function 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 role: "tool" 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[] = [] - - message.content.forEach((part) => { - if (part.type === "text") { - textParts.push(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}` }, - }) - } - }) - - 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 + for (const message of messages) { + if (message.role === "user") { + processUserMessage(message, result) + } else if (message.role === "assistant") { + processAssistantMessage(message, result) } + } - // 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 result +} + +/** + * Process a user message, handling tool_result blocks separately from text/image content + */ +function processUserMessage(message: AnthropicMessage, result: Message[]): void { + if (typeof message.content === "string") { + // Simple string content - merge with previous user message if possible + mergeOrAddUserMessage(message.content, result) + return + } + + // Separate tool_result blocks from other content + const toolResults: Anthropic.ToolResultBlockParam[] = [] + const otherContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] + + for (const block of message.content) { + if (block.type === "tool_result") { + toolResults.push(block) + } else if (block.type === "text" || block.type === "image") { + otherContent.push(block) + } + // Ignore other block types (user cannot send tool_use) + } + + // Process tool_result blocks first - they become separate "tool" messages + // This must come before user content to maintain correct message order + for (const toolResult of toolResults) { + const content = + typeof toolResult.content === "string" + ? toolResult.content + : (toolResult.content?.map((part) => (part.type === "text" ? part.text : "")).join("\n") ?? "") + + const toolMessage: ToolMessage = { + role: "tool", + tool_call_id: toolResult.tool_use_id, + content: content, + } + result.push(toolMessage) + } + + // Process remaining content (text and images) + if (otherContent.length > 0) { + const { content, hasImages } = convertUserContent(otherContent) + + if (hasImages) { + // If there are images, try to merge with previous user message if possible + mergeOrAddUserMessageWithArrayContent(content as (ContentPartText | ContentPartImage)[], result) + } else { + // Text only - can merge with previous user message + const textContent = + typeof content === "string" ? content : (content as ContentPartText[]).map((p) => p.text).join("\n") + mergeOrAddUserMessage(textContent, result) + } + } +} + +/** + * Process an assistant message, handling tool_use blocks as tool_calls + */ +function processAssistantMessage(message: AnthropicMessage, result: Message[]): void { + if (typeof message.content === "string") { + // Simple string content - merge with previous assistant message if possible + mergeOrAddAssistantMessage(message.content, undefined, result) + return + } + + // Separate tool_use blocks from other content + const toolUses: Anthropic.ToolUseBlockParam[] = [] + const otherContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] + + for (const block of message.content) { + if (block.type === "tool_use") { + toolUses.push(block) + } else if (block.type === "text" || block.type === "image") { + otherContent.push(block) + } + // Ignore other block types (assistant cannot send tool_result) + } + + // Convert text content - preserve empty strings + let textContent: string | undefined + if (otherContent.length > 0) { + const texts = otherContent + .filter((part) => part.type === "text") + .map((part) => (part as Anthropic.TextBlockParam).text) + // If there were text blocks, join them (even if empty) + // If there were no text blocks, textContent remains undefined + if (texts.length > 0) { + textContent = texts.join("\n") + } + } + + // Convert tool_use blocks to tool_calls + let toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined + if (toolUses.length > 0) { + toolCalls = toolUses.map((toolUse) => ({ + id: toolUse.id, + type: "function" as const, + function: { + name: toolUse.name, + arguments: JSON.stringify(toolUse.input), + }, + })) + } + + // If there are tool calls, we cannot merge (tool_calls must be in their own message) + if (toolCalls && toolCalls.length > 0) { + const assistantMessage: AssistantMessage = { + role: "assistant", + content: textContent || null, + tool_calls: toolCalls, + } + result.push(assistantMessage) + } else if (textContent !== undefined) { + // No tool calls - can merge with previous assistant message + // Note: textContent can be empty string "" which should be preserved + mergeOrAddAssistantMessage(textContent, undefined, result) + } +} + +/** + * Convert user content blocks to OpenAI format + */ +function convertUserContent(content: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]): { + content: string | (ContentPartText | ContentPartImage)[] + hasImages: boolean +} { + const textParts: string[] = [] + const imageParts: ContentPartImage[] = [] + let hasImages = false + + 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}` }, + }) + } + } + + if (hasImages) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + return { content: parts, hasImages: true } + } + + return { content: textParts.join("\n"), hasImages: false } +} + +/** + * Merge text content with the previous user message if possible, or add a new one + */ +function mergeOrAddUserMessage(content: string, result: Message[]): void { + const lastMessage = result[result.length - 1] + + if (lastMessage?.role === "user") { + // Merge with previous user message + if (typeof lastMessage.content === "string") { + lastMessage.content = lastMessage.content + "\n" + content + } else if (Array.isArray(lastMessage.content)) { + // Previous message has array content - add text to it + lastMessage.content.push({ type: "text", text: content }) + } + } else { + // Add new user message + result.push({ role: "user", content }) + } +} + +/** + * Merge array content (with images) with the previous user message if possible, or add a new one + */ +function mergeOrAddUserMessageWithArrayContent( + content: (ContentPartText | ContentPartImage)[], + result: Message[], +): void { + const lastMessage = result[result.length - 1] + + if (lastMessage?.role === "user") { + // Merge with previous user message + if (typeof lastMessage.content === "string") { + // Convert string to array and append new content + lastMessage.content = [{ type: "text", text: lastMessage.content }, ...content] + } else if (Array.isArray(lastMessage.content)) { + // Previous message has array content - append new content + lastMessage.content.push(...content) + } + } else { + // Add new user message + result.push({ role: "user", content }) + } +} + +/** + * Merge text content with the previous assistant message if possible, or add a new one + */ +function mergeOrAddAssistantMessage( + content: string | undefined, + toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined, + result: Message[], +): void { + const lastMessage = result[result.length - 1] + const hasContent = content !== undefined + + // Can only merge if there are no tool_calls and the previous message is an assistant without tool_calls + if ( + lastMessage?.role === "assistant" && + !toolCalls && + !(lastMessage as AssistantMessage).tool_calls && + hasContent && + content // Only merge non-empty content + ) { + // Merge with previous assistant message + if (typeof lastMessage.content === "string") { + lastMessage.content = lastMessage.content + "\n" + content + } else if (lastMessage.content === null || lastMessage.content === undefined) { + lastMessage.content = content + } + } else if (hasContent || toolCalls) { + // Add new assistant message (including empty string content) + const assistantMessage: AssistantMessage = { + role: "assistant", + content: content ?? null, + ...(toolCalls && toolCalls.length > 0 && { tool_calls: toolCalls }), + } + result.push(assistantMessage) + } }