diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index 80e641d94d..f9c5b1e6e7 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -179,4 +179,276 @@ describe("convertToR1Format", () => { expect(convertToR1Format(input)).toEqual(expected) }) + + describe("tool handling", () => { + it("should handle assistant messages with tool_use blocks", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Let me read that file for you.", + }, + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "/path/to/file.txt" }, + }, + ], + }, + ] + + const result = convertToR1Format(input) + expect(result).toHaveLength(1) + + const assistantMessage = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMessage.role).toBe("assistant") + expect(assistantMessage.content).toBe("Let me read that file for you.") + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls![0]).toEqual({ + id: "tool-123", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "/path/to/file.txt" }), + }, + }) + }) + + it("should handle user messages with tool_result blocks", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents here", + }, + ], + }, + ] + + const result = convertToR1Format(input) + expect(result).toHaveLength(1) + + const toolMessage = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.role).toBe("tool") + expect(toolMessage.tool_call_id).toBe("tool-123") + expect(toolMessage.content).toBe("File contents here") + }) + + it("should handle multiple tool_use blocks in one assistant 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 assistantMessage = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMessage.tool_calls).toHaveLength(2) + expect(assistantMessage.tool_calls![0].id).toBe("tool-1") + expect(assistantMessage.tool_calls![1].id).toBe("tool-2") + }) + + it("should handle multiple tool_result blocks in one user message", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Content of file 1", + }, + { + type: "tool_result", + tool_use_id: "tool-2", + content: "Content of file 2", + }, + ], + }, + ] + + const result = convertToR1Format(input) + expect(result).toHaveLength(2) + + const toolMessage1 = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage1.role).toBe("tool") + expect(toolMessage1.tool_call_id).toBe("tool-1") + expect(toolMessage1.content).toBe("Content of file 1") + + const toolMessage2 = result[1] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage2.role).toBe("tool") + expect(toolMessage2.tool_call_id).toBe("tool-2") + expect(toolMessage2.content).toBe("Content of file 2") + }) + + it("should handle full tool conversation flow", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file at /test.txt" }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll read that file for you.", + }, + { + 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 contains: Hello, World!", + }, + ] + + const result = convertToR1Format(input) + expect(result).toHaveLength(4) + + // First user message + expect(result[0]).toEqual({ role: "user", content: "Read the file at /test.txt" }) + + // Assistant with tool call + const assistantWithTool = result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantWithTool.role).toBe("assistant") + expect(assistantWithTool.content).toBe("I'll read that file for you.") + expect(assistantWithTool.tool_calls).toHaveLength(1) + + // Tool result + const toolResult = result[2] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolResult.role).toBe("tool") + expect(toolResult.tool_call_id).toBe("tool-abc") + expect(toolResult.content).toBe("Hello, World!") + + // Final assistant response + expect(result[3]).toEqual({ role: "assistant", content: "The file contains: 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-xyz", + content: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ], + }, + ], + }, + ] + + const result = convertToR1Format(input) + expect(result).toHaveLength(1) + + const toolMessage = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.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: "tool-123", + content: "Tool output", + }, + { + type: "text", + text: "Here's some additional context", + }, + ], + }, + ] + + const result = convertToR1Format(input) + // Should have tool message first, then user message + expect(result).toHaveLength(2) + + const toolMessage = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.role).toBe("tool") + expect(toolMessage.content).toBe("Tool output") + + const userMessage = result[1] as OpenAI.Chat.ChatCompletionUserMessageParam + expect(userMessage.role).toBe("user") + expect(userMessage.content).toBe("Here's some additional context") + }) + + it("should not merge assistant messages with tool_calls", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "First action", + }, + { + type: "tool_use", + id: "tool-1", + name: "action_1", + input: {}, + }, + ], + }, + { + role: "assistant", + content: "Second message", + }, + ] + + const result = convertToR1Format(input) + // Should NOT be merged because first message has tool_calls + expect(result).toHaveLength(2) + + const firstAssistant = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(firstAssistant.tool_calls).toHaveLength(1) + + expect(result[1]).toEqual({ role: "assistant", content: "Second message" }) + }) + }) }) diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index 51a4b94dbc..9020d8497f 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 @@ -16,83 +17,192 @@ type AnthropicMessage = Anthropic.Messages.MessageParam * @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 + for (const message of messages) { + // Handle array content (may contain tool_use, tool_result, text, image) if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] + if (message.role === "user") { + // Separate tool_result blocks from other content + const toolResults: Anthropic.ToolResultBlockParam[] = [] + const nonToolContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] - message.content.forEach((part) => { - if (part.type === "text") { - textParts.push(part.text) + message.content.forEach((part) => { + if (part.type === "tool_result") { + toolResults.push(part) + } else if (part.type === "text" || part.type === "image") { + nonToolContent.push(part) + } + }) + + // Process tool result messages - these become "tool" role messages + toolResults.forEach((toolResult) => { + let content: string + if (typeof toolResult.content === "string") { + content = toolResult.content + } else { + content = + toolResult.content + ?.map((part) => { + if (part.type === "image") { + return "(see following user message for image)" + } + return part.text + }) + .join("\n") ?? "" + } + + const toolMessage: ToolMessage = { + role: "tool", + tool_call_id: toolResult.tool_use_id, + content: content, + } + result.push(toolMessage) + }) + + // Process non-tool content + if (nonToolContent.length > 0) { + const messageContent = convertUserContent(nonToolContent) + addOrMergeMessage(result, "user", messageContent) } - 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 (message.role === "assistant") { + // Separate tool_use blocks from other content + const toolUses: Anthropic.ToolUseBlockParam[] = [] + const nonToolContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] + + message.content.forEach((part) => { + if (part.type === "tool_use") { + toolUses.push(part) + } else if (part.type === "text" || part.type === "image") { + nonToolContent.push(part) + } + }) + + // Build assistant message with optional tool_calls + let textContent: string | undefined + if (nonToolContent.length > 0) { + textContent = nonToolContent + .map((part) => { + if (part.type === "image") { + return "" // assistant cannot send images + } + return part.text + }) + .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 - } + const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined = + toolUses.length > 0 + ? toolUses.map((toolUse) => ({ + id: toolUse.id, + type: "function" as const, + function: { + name: toolUse.name, + arguments: JSON.stringify(toolUse.input), + }, + })) + : undefined - // 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 + // If we have tool_calls, we can't merge with previous message + if (toolCalls) { + const assistantMessage: AssistantMessage = { + role: "assistant", + content: textContent, + tool_calls: toolCalls, + } + result.push(assistantMessage) + } else if (textContent !== undefined) { + addOrMergeMessage(result, "assistant", textContent) } } } 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) - } + // Simple string content + addOrMergeMessage(result, message.role, message.content) } + } - return merged - }, []) + return result +} + +/** + * Converts Anthropic user content blocks to OpenAI format + */ +function convertUserContent( + content: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[], +): string | (ContentPartText | ContentPartImage)[] { + const textParts: string[] = [] + const imageParts: ContentPartImage[] = [] + let hasImages = false + + 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) + return parts + } + + return textParts.join("\n") +} + +/** + * Adds a message to the result array, merging with the last message if roles match + */ +function addOrMergeMessage( + result: Message[], + role: "user" | "assistant", + content: string | (ContentPartText | ContentPartImage)[], +): void { + const lastMessage = result[result.length - 1] + + // Can only merge if last message has same role and no tool_calls + if (lastMessage?.role === role && !("tool_calls" in lastMessage && lastMessage.tool_calls)) { + if (typeof lastMessage.content === "string" && typeof content === "string") { + lastMessage.content += `\n${content}` + } else { + // If either has image content, convert both to array format + 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) + } + } }