diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 021f2834..f0c5caa3 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -15,6 +15,7 @@ import { type AnthropicMessagesPayload, type AnthropicResponse, type AnthropicTextBlock, + type AnthropicThinkingBlock, type AnthropicTool, type AnthropicToolResultBlock, type AnthropicToolUseBlock, @@ -131,11 +132,21 @@ function handleAssistantMessage( (block): block is AnthropicTextBlock => block.type === "text", ) + const thinkingBlocks = message.content.filter( + (block): block is AnthropicThinkingBlock => block.type === "thinking", + ) + + // Combine text and thinking blocks, as OpenAI doesn't have separate thinking blocks + const allTextContent = [ + ...textBlocks.map((b) => b.text), + ...thinkingBlocks.map((b) => b.thinking), + ].join("\n\n") + return toolUseBlocks.length > 0 ? [ { role: "assistant", - content: textBlocks.map((b) => b.text).join("\n\n") || null, + content: allTextContent || null, tool_calls: toolUseBlocks.map((toolUse) => ({ id: toolUse.id, type: "function", @@ -169,22 +180,38 @@ function mapContent( const hasImage = content.some((block) => block.type === "image") if (!hasImage) { return content - .filter((block): block is AnthropicTextBlock => block.type === "text") - .map((block) => block.text) + .filter( + (block): block is AnthropicTextBlock | AnthropicThinkingBlock => + block.type === "text" || block.type === "thinking", + ) + .map((block) => (block.type === "text" ? block.text : block.thinking)) .join("\n\n") } const contentParts: Array = [] for (const block of content) { - if (block.type === "text") { - contentParts.push({ type: "text", text: block.text }) - } else if (block.type === "image") { - contentParts.push({ - type: "image_url", - image_url: { - url: `data:${block.source.media_type};base64,${block.source.data}`, - }, - }) + switch (block.type) { + case "text": { + contentParts.push({ type: "text", text: block.text }) + + break + } + case "thinking": { + contentParts.push({ type: "text", text: block.thinking }) + + break + } + case "image": { + contentParts.push({ + type: "image_url", + image_url: { + url: `data:${block.source.media_type};base64,${block.source.data}`, + }, + }) + + break + } + // No default } } return contentParts @@ -246,6 +273,7 @@ export function translateToAnthropic( const choice = response.choices[0] const textBlocks = getAnthropicTextBlocks(choice.message.content) const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls) + // Note: GitHub Copilot doesn't generate thinking blocks, so we don't include them in responses return { id: response.id, diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 78979485..a4a5b06b 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -124,6 +124,79 @@ describe("Anthropic to OpenAI translation logic", () => { // Should fail validation expect(isValidChatCompletionRequest(openAIPayload)).toBe(false) }) + + test("should handle thinking blocks in assistant messages", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-3-5-sonnet-20241022", + messages: [ + { role: "user", content: "What is 2+2?" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Let me think about this simple math problem...", + }, + { type: "text", text: "2+2 equals 4." }, + ], + }, + ], + max_tokens: 100, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(isValidChatCompletionRequest(openAIPayload)).toBe(true) + + // Check that thinking content is combined with text content + const assistantMessage = openAIPayload.messages.find( + (m) => m.role === "assistant", + ) + expect(assistantMessage?.content).toContain( + "Let me think about this simple math problem...", + ) + expect(assistantMessage?.content).toContain("2+2 equals 4.") + }) + + test("should handle thinking blocks with tool calls", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-3-5-sonnet-20241022", + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: + "I need to call the weather API to get current weather information.", + }, + { type: "text", text: "I'll check the weather for you." }, + { + type: "tool_use", + id: "call_123", + name: "get_weather", + input: { location: "New York" }, + }, + ], + }, + ], + max_tokens: 100, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(isValidChatCompletionRequest(openAIPayload)).toBe(true) + + // Check that thinking content is included in the message content + const assistantMessage = openAIPayload.messages.find( + (m) => m.role === "assistant", + ) + expect(assistantMessage?.content).toContain( + "I need to call the weather API", + ) + expect(assistantMessage?.content).toContain( + "I'll check the weather for you.", + ) + expect(assistantMessage?.tool_calls).toHaveLength(1) + expect(assistantMessage?.tool_calls?.[0].function.name).toBe("get_weather") + }) }) describe("OpenAI Chat Completion v1 Request Payload Validation with Zod", () => {