Skip to content

Commit 0d3c786

Browse files
committed
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 ericc-ch#61
1 parent d2a9e2d commit 0d3c786

File tree

4 files changed

+71
-4
lines changed

4 files changed

+71
-4
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"*": "bun run lint --fix"
4040
},
4141
"dependencies": {
42+
"bun": "^1.2.19",
4243
"citty": "^0.1.6",
4344
"clipboardy": "^4.0.0",
4445
"consola": "^3.4.2",

src/routes/messages/non-stream-translation.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type AnthropicMessagesPayload,
1616
type AnthropicResponse,
1717
type AnthropicTextBlock,
18+
type AnthropicThinkingBlock,
1819
type AnthropicTool,
1920
type AnthropicToolResultBlock,
2021
type AnthropicToolUseBlock,
@@ -131,11 +132,21 @@ function handleAssistantMessage(
131132
(block): block is AnthropicTextBlock => block.type === "text",
132133
)
133134

135+
const thinkingBlocks = message.content.filter(
136+
(block): block is AnthropicThinkingBlock => block.type === "thinking",
137+
)
138+
139+
// Combine text and thinking blocks, as OpenAI doesn't have separate thinking blocks
140+
const allTextContent = [
141+
...textBlocks.map((b) => b.text),
142+
...thinkingBlocks.map((b) => b.thinking),
143+
].join("\n\n")
144+
134145
return toolUseBlocks.length > 0 ?
135146
[
136147
{
137148
role: "assistant",
138-
content: textBlocks.map((b) => b.text).join("\n\n") || null,
149+
content: allTextContent || null,
139150
tool_calls: toolUseBlocks.map((toolUse) => ({
140151
id: toolUse.id,
141152
type: "function",
@@ -169,15 +180,18 @@ function mapContent(
169180
const hasImage = content.some((block) => block.type === "image")
170181
if (!hasImage) {
171182
return content
172-
.filter((block): block is AnthropicTextBlock => block.type === "text")
173-
.map((block) => block.text)
183+
.filter((block): block is AnthropicTextBlock | AnthropicThinkingBlock =>
184+
block.type === "text" || block.type === "thinking")
185+
.map((block) => block.type === "text" ? block.text : block.thinking)
174186
.join("\n\n")
175187
}
176188

177189
const contentParts: Array<ContentPart> = []
178190
for (const block of content) {
179191
if (block.type === "text") {
180192
contentParts.push({ type: "text", text: block.text })
193+
} else if (block.type === "thinking") {
194+
contentParts.push({ type: "text", text: block.thinking })
181195
} else if (block.type === "image") {
182196
contentParts.push({
183197
type: "image_url",
@@ -246,6 +260,7 @@ export function translateToAnthropic(
246260
const choice = response.choices[0]
247261
const textBlocks = getAnthropicTextBlocks(choice.message.content)
248262
const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls)
263+
// Note: GitHub Copilot doesn't generate thinking blocks, so we don't include them in responses
249264

250265
return {
251266
id: response.id,

tests/anthropic-request.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,57 @@ describe("Anthropic to OpenAI translation logic", () => {
124124
// Should fail validation
125125
expect(isValidChatCompletionRequest(openAIPayload)).toBe(false)
126126
})
127+
128+
test("should handle thinking blocks in assistant messages", () => {
129+
const anthropicPayload: AnthropicMessagesPayload = {
130+
model: "gpt-4o",
131+
messages: [
132+
{ role: "user", content: "What is 2+2?" },
133+
{
134+
role: "assistant",
135+
content: [
136+
{ type: "thinking", thinking: "Let me think about this simple math problem..." },
137+
{ type: "text", text: "2+2 equals 4." },
138+
],
139+
},
140+
],
141+
max_tokens: 100,
142+
}
143+
const openAIPayload = translateToOpenAI(anthropicPayload)
144+
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
145+
146+
// Check that thinking content is combined with text content
147+
const assistantMessage = openAIPayload.messages.find(m => m.role === "assistant")
148+
expect(assistantMessage?.content).toContain("Let me think about this simple math problem...")
149+
expect(assistantMessage?.content).toContain("2+2 equals 4.")
150+
})
151+
152+
test("should handle thinking blocks with tool calls", () => {
153+
const anthropicPayload: AnthropicMessagesPayload = {
154+
model: "gpt-4o",
155+
messages: [
156+
{ role: "user", content: "What's the weather?" },
157+
{
158+
role: "assistant",
159+
content: [
160+
{ type: "thinking", thinking: "I need to call the weather API to get current weather information." },
161+
{ type: "text", text: "I'll check the weather for you." },
162+
{ type: "tool_use", id: "call_123", name: "get_weather", input: { location: "New York" } },
163+
],
164+
},
165+
],
166+
max_tokens: 100,
167+
}
168+
const openAIPayload = translateToOpenAI(anthropicPayload)
169+
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
170+
171+
// Check that thinking content is included in the message content
172+
const assistantMessage = openAIPayload.messages.find(m => m.role === "assistant")
173+
expect(assistantMessage?.content).toContain("I need to call the weather API")
174+
expect(assistantMessage?.content).toContain("I'll check the weather for you.")
175+
expect(assistantMessage?.tool_calls).toHaveLength(1)
176+
expect(assistantMessage?.tool_calls?.[0].function.name).toBe("get_weather")
177+
})
127178
})
128179

129180
describe("OpenAI Chat Completion v1 Request Payload Validation with Zod", () => {

tests/anthropic-response.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const anthropicStreamEventSchema = z
6363
"message_stop",
6464
]),
6565
})
66-
.passthrough()
66+
.loose()
6767

6868
function isValidAnthropicStreamEvent(payload: unknown): boolean {
6969
return anthropicStreamEventSchema.safeParse(payload).success

0 commit comments

Comments
 (0)