From 0d3c786c8bf38bea0691c77c5bc3935bcdd2c165 Mon Sep 17 00:00:00 2001 From: Josh Lane Date: Fri, 25 Jul 2025 13:06:22 -0700 Subject: [PATCH 1/5] feat: add support for Anthropic thinking blocks - Add proper handling of thinking blocks in non-stream translation - Filter and combine thinking blocks with text blocks for OpenAI compatibility - Update mapContent function to handle thinking blocks in both string and ContentPart formats - Add comprehensive tests for thinking blocks with and without tool calls - Since GitHub Copilot doesn't support thinking blocks natively, merge thinking content with text Fixes #61 --- package.json | 1 + src/routes/messages/non-stream-translation.ts | 21 ++++++-- tests/anthropic-request.test.ts | 51 +++++++++++++++++++ tests/anthropic-response.test.ts | 2 +- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 0b026bbb..632b51bb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "*": "bun run lint --fix" }, "dependencies": { + "bun": "^1.2.19", "citty": "^0.1.6", "clipboardy": "^4.0.0", "consola": "^3.4.2", diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 021f2834..4a351abb 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,8 +180,9 @@ 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") } @@ -178,6 +190,8 @@ function mapContent( for (const block of content) { if (block.type === "text") { contentParts.push({ type: "text", text: block.text }) + } else if (block.type === "thinking") { + contentParts.push({ type: "text", text: block.thinking }) } else if (block.type === "image") { contentParts.push({ type: "image_url", @@ -246,6 +260,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..51622935 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -124,6 +124,57 @@ 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: "gpt-4o", + 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: "gpt-4o", + 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", () => { diff --git a/tests/anthropic-response.test.ts b/tests/anthropic-response.test.ts index 352f06ea..648b3a65 100644 --- a/tests/anthropic-response.test.ts +++ b/tests/anthropic-response.test.ts @@ -63,7 +63,7 @@ const anthropicStreamEventSchema = z "message_stop", ]), }) - .passthrough() + .loose() function isValidAnthropicStreamEvent(payload: unknown): boolean { return anthropicStreamEventSchema.safeParse(payload).success From b4d77020a4215404c8ced49889482cff29177005 Mon Sep 17 00:00:00 2001 From: Josh Lane Date: Fri, 25 Jul 2025 13:32:48 -0700 Subject: [PATCH 2/5] fix: replace .loose() with .passthrough() in Zod schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .loose() method doesn't exist in Zod - replace with .passthrough() to allow additional properties in the anthropicStreamEventSchema. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/anthropic-response.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/anthropic-response.test.ts b/tests/anthropic-response.test.ts index 648b3a65..352f06ea 100644 --- a/tests/anthropic-response.test.ts +++ b/tests/anthropic-response.test.ts @@ -63,7 +63,7 @@ const anthropicStreamEventSchema = z "message_stop", ]), }) - .loose() + .passthrough() function isValidAnthropicStreamEvent(payload: unknown): boolean { return anthropicStreamEventSchema.safeParse(payload).success From 94ac0fd4b5da9f7691742e7fb694ad89129f101c Mon Sep 17 00:00:00 2001 From: Josh Lane Date: Fri, 25 Jul 2025 13:35:48 -0700 Subject: [PATCH 3/5] test: use claude-3-5-sonnet-20241022 for thinking blocks tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since thinking blocks are an Anthropic-specific feature, the tests should use an Anthropic model instead of gpt-4o for accuracy and clarity. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/anthropic-request.test.ts | 46 ++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 51622935..a4a5b06b 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -127,13 +127,16 @@ describe("Anthropic to OpenAI translation logic", () => { test("should handle thinking blocks in assistant messages", () => { const anthropicPayload: AnthropicMessagesPayload = { - model: "gpt-4o", + 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: "thinking", + thinking: "Let me think about this simple math problem...", + }, { type: "text", text: "2+2 equals 4." }, ], }, @@ -142,24 +145,37 @@ describe("Anthropic to OpenAI translation logic", () => { } 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...") + 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: "gpt-4o", + 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: "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" } }, + { + type: "tool_use", + id: "call_123", + name: "get_weather", + input: { location: "New York" }, + }, ], }, ], @@ -167,11 +183,17 @@ describe("Anthropic to OpenAI translation logic", () => { } 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.") + 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") }) From efea9219dfecb85b07a9e88e60087f0ef12f2835 Mon Sep 17 00:00:00 2001 From: Erick Christian Date: Sun, 27 Jul 2025 00:42:03 +0700 Subject: [PATCH 4/5] chore: remove bun from package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 632b51bb..0b026bbb 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "*": "bun run lint --fix" }, "dependencies": { - "bun": "^1.2.19", "citty": "^0.1.6", "clipboardy": "^4.0.0", "consola": "^3.4.2", From 52b8cb1a4aa365463bcb51f2ab9257a12b619735 Mon Sep 17 00:00:00 2001 From: Erick Christian Date: Sun, 27 Jul 2025 00:42:32 +0700 Subject: [PATCH 5/5] chore: run lint --- src/routes/messages/non-stream-translation.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 4a351abb..f0c5caa3 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -180,25 +180,38 @@ function mapContent( const hasImage = content.some((block) => block.type === "image") if (!hasImage) { return content - .filter((block): block is AnthropicTextBlock | AnthropicThinkingBlock => - block.type === "text" || block.type === "thinking") - .map((block) => block.type === "text" ? block.text : block.thinking) + .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 === "thinking") { - contentParts.push({ type: "text", text: block.thinking }) - } 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