Browse Source

Merge pull request #1167 from RooVetGit/cte/claude-3.7-thinking

Claude 3.7 thinking
Chris Estreich 10 months ago
parent
commit
6763824b5b

+ 5 - 4
package-lock.json

@@ -9,7 +9,7 @@
 			"version": "3.7.2",
 			"dependencies": {
 				"@anthropic-ai/bedrock-sdk": "^0.10.2",
-				"@anthropic-ai/sdk": "^0.26.0",
+				"@anthropic-ai/sdk": "^0.37.0",
 				"@anthropic-ai/vertex-sdk": "^0.4.1",
 				"@aws-sdk/client-bedrock-runtime": "^3.706.0",
 				"@google/generative-ai": "^0.18.0",
@@ -122,9 +122,10 @@
 			}
 		},
 		"node_modules/@anthropic-ai/sdk": {
-			"version": "0.26.1",
-			"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.26.1.tgz",
-			"integrity": "sha512-HeMJP1bDFfQPQS3XTJAmfXkFBdZ88wvfkE05+vsoA9zGn5dHqEaHOPsqkazf/i0gXYg2XlLxxZrf6rUAarSqzw==",
+			"version": "0.37.0",
+			"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
+			"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
+			"license": "MIT",
 			"dependencies": {
 				"@types/node": "^18.11.18",
 				"@types/node-fetch": "^2.6.4",

+ 1 - 1
package.json

@@ -304,7 +304,7 @@
 	},
 	"dependencies": {
 		"@anthropic-ai/bedrock-sdk": "^0.10.2",
-		"@anthropic-ai/sdk": "^0.26.0",
+		"@anthropic-ai/sdk": "^0.37.0",
 		"@anthropic-ai/vertex-sdk": "^0.4.1",
 		"@aws-sdk/client-bedrock-runtime": "^3.706.0",
 		"@google/generative-ai": "^0.18.0",

+ 18 - 58
src/api/providers/__tests__/anthropic.test.ts

@@ -1,50 +1,13 @@
+// npx jest src/api/providers/__tests__/anthropic.test.ts
+
 import { AnthropicHandler } from "../anthropic"
 import { ApiHandlerOptions } from "../../../shared/api"
-import { ApiStream } from "../../transform/stream"
-import { Anthropic } from "@anthropic-ai/sdk"
 
-// Mock Anthropic client
-const mockBetaCreate = jest.fn()
 const mockCreate = jest.fn()
+
 jest.mock("@anthropic-ai/sdk", () => {
 	return {
 		Anthropic: jest.fn().mockImplementation(() => ({
-			beta: {
-				promptCaching: {
-					messages: {
-						create: mockBetaCreate.mockImplementation(async () => ({
-							async *[Symbol.asyncIterator]() {
-								yield {
-									type: "message_start",
-									message: {
-										usage: {
-											input_tokens: 100,
-											output_tokens: 50,
-											cache_creation_input_tokens: 20,
-											cache_read_input_tokens: 10,
-										},
-									},
-								}
-								yield {
-									type: "content_block_start",
-									index: 0,
-									content_block: {
-										type: "text",
-										text: "Hello",
-									},
-								}
-								yield {
-									type: "content_block_delta",
-									delta: {
-										type: "text_delta",
-										text: " world",
-									},
-								}
-							},
-						})),
-					},
-				},
-			},
 			messages: {
 				create: mockCreate.mockImplementation(async (options) => {
 					if (!options.stream) {
@@ -65,16 +28,26 @@ jest.mock("@anthropic-ai/sdk", () => {
 								type: "message_start",
 								message: {
 									usage: {
-										input_tokens: 10,
-										output_tokens: 5,
+										input_tokens: 100,
+										output_tokens: 50,
+										cache_creation_input_tokens: 20,
+										cache_read_input_tokens: 10,
 									},
 								},
 							}
 							yield {
 								type: "content_block_start",
+								index: 0,
 								content_block: {
 									type: "text",
-									text: "Test response",
+									text: "Hello",
+								},
+							}
+							yield {
+								type: "content_block_delta",
+								delta: {
+									type: "text_delta",
+									text: " world",
 								},
 							}
 						},
@@ -95,7 +68,6 @@ describe("AnthropicHandler", () => {
 			apiModelId: "claude-3-5-sonnet-20241022",
 		}
 		handler = new AnthropicHandler(mockOptions)
-		mockBetaCreate.mockClear()
 		mockCreate.mockClear()
 	})
 
@@ -126,17 +98,6 @@ describe("AnthropicHandler", () => {
 
 	describe("createMessage", () => {
 		const systemPrompt = "You are a helpful assistant."
-		const messages: Anthropic.Messages.MessageParam[] = [
-			{
-				role: "user",
-				content: [
-					{
-						type: "text" as const,
-						text: "Hello!",
-					},
-				],
-			},
-		]
 
 		it("should handle prompt caching for supported models", async () => {
 			const stream = handler.createMessage(systemPrompt, [
@@ -173,9 +134,8 @@ describe("AnthropicHandler", () => {
 			expect(textChunks[0].text).toBe("Hello")
 			expect(textChunks[1].text).toBe(" world")
 
-			// Verify beta API was used
-			expect(mockBetaCreate).toHaveBeenCalled()
-			expect(mockCreate).not.toHaveBeenCalled()
+			// Verify API
+			expect(mockCreate).toHaveBeenCalled()
 		})
 	})
 

+ 63 - 34
src/api/providers/anthropic.ts

@@ -1,5 +1,7 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
+import { CacheControlEphemeral } from "@anthropic-ai/sdk/resources"
+import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
 import {
 	anthropicDefaultModelId,
 	AnthropicModelId,
@@ -12,12 +14,15 @@ import { ApiStream } from "../transform/stream"
 
 const ANTHROPIC_DEFAULT_TEMPERATURE = 0
 
+const THINKING_MODELS = ["claude-3-7-sonnet-20250219"]
+
 export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
 	private options: ApiHandlerOptions
 	private client: Anthropic
 
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
+
 		this.client = new Anthropic({
 			apiKey: this.options.apiKey,
 			baseURL: this.options.anthropicBaseUrl || undefined,
@@ -25,47 +30,57 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
 	}
 
 	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
-		let stream: AnthropicStream<Anthropic.Beta.PromptCaching.Messages.RawPromptCachingBetaMessageStreamEvent>
+		let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
+		const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
 		const modelId = this.getModel().id
+		const maxTokens = this.getModel().info.maxTokens || 8192
+		let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
+		let thinking: BetaThinkingConfigParam | undefined = undefined
+
+		if (THINKING_MODELS.includes(modelId)) {
+			thinking = this.options.anthropicThinking
+				? { type: "enabled", budget_tokens: this.options.anthropicThinking }
+				: { type: "disabled" }
+
+			temperature = 1.0
+		}
 
 		switch (modelId) {
-			// 'latest' alias does not support cache_control
 			case "claude-3-7-sonnet-20250219":
 			case "claude-3-5-sonnet-20241022":
 			case "claude-3-5-haiku-20241022":
 			case "claude-3-opus-20240229":
 			case "claude-3-haiku-20240307": {
-				/*
-				The latest message will be the new user message, one before will be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
-				*/
+				/**
+				 * The latest message will be the new user message, one before will
+				 * be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
+				 */
 				const userMsgIndices = messages.reduce(
 					(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
 					[] as number[],
 				)
+
 				const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
 				const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
-				stream = await this.client.beta.promptCaching.messages.create(
+
+				stream = await this.client.messages.create(
 					{
 						model: modelId,
-						max_tokens: this.getModel().info.maxTokens || 8192,
-						temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE,
-						system: [{ text: systemPrompt, type: "text", cache_control: { type: "ephemeral" } }], // setting cache breakpoint for system prompt so new tasks can reuse it
+						max_tokens: maxTokens,
+						temperature,
+						thinking,
+						// Setting cache breakpoint for system prompt so new tasks can reuse it.
+						system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }],
 						messages: messages.map((message, index) => {
 							if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
 								return {
 									...message,
 									content:
 										typeof message.content === "string"
-											? [
-													{
-														type: "text",
-														text: message.content,
-														cache_control: { type: "ephemeral" },
-													},
-												]
+											? [{ type: "text", text: message.content, cache_control: cacheControl }]
 											: message.content.map((content, contentIndex) =>
 													contentIndex === message.content.length - 1
-														? { ...content, cache_control: { type: "ephemeral" } }
+														? { ...content, cache_control: cacheControl }
 														: content,
 												),
 								}
@@ -114,8 +129,9 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
 		for await (const chunk of stream) {
 			switch (chunk.type) {
 				case "message_start":
-					// tells us cache reads/writes/input/output
+					// Tells us cache reads/writes/input/output.
 					const usage = chunk.message.usage
+
 					yield {
 						type: "usage",
 						inputTokens: usage.input_tokens || 0,
@@ -123,45 +139,53 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
 						cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
 						cacheReadTokens: usage.cache_read_input_tokens || undefined,
 					}
+
 					break
 				case "message_delta":
-					// tells us stop_reason, stop_sequence, and output tokens along the way and at the end of the message
-
+					// Tells us stop_reason, stop_sequence, and output tokens
+					// along the way and at the end of the message.
 					yield {
 						type: "usage",
 						inputTokens: 0,
 						outputTokens: chunk.usage.output_tokens || 0,
 					}
+
 					break
 				case "message_stop":
-					// no usage data, just an indicator that the message is done
+					// No usage data, just an indicator that the message is done.
 					break
 				case "content_block_start":
 					switch (chunk.content_block.type) {
-						case "text":
-							// we may receive multiple text blocks, in which case just insert a line break between them
+						case "thinking":
+							// We may receive multiple text blocks, in which
+							// case just insert a line break between them.
 							if (chunk.index > 0) {
-								yield {
-									type: "text",
-									text: "\n",
-								}
+								yield { type: "reasoning", text: "\n" }
 							}
-							yield {
-								type: "text",
-								text: chunk.content_block.text,
+
+							yield { type: "reasoning", text: chunk.content_block.thinking }
+							break
+						case "text":
+							// We may receive multiple text blocks, in which
+							// case just insert a line break between them.
+							if (chunk.index > 0) {
+								yield { type: "text", text: "\n" }
 							}
+
+							yield { type: "text", text: chunk.content_block.text }
 							break
 					}
 					break
 				case "content_block_delta":
 					switch (chunk.delta.type) {
+						case "thinking_delta":
+							yield { type: "reasoning", text: chunk.delta.thinking }
+							break
 						case "text_delta":
-							yield {
-								type: "text",
-								text: chunk.delta.text,
-							}
+							yield { type: "text", text: chunk.delta.text }
 							break
 					}
+
 					break
 				case "content_block_stop":
 					break
@@ -171,10 +195,12 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
 
 	getModel(): { id: AnthropicModelId; info: ModelInfo } {
 		const modelId = this.options.apiModelId
+
 		if (modelId && modelId in anthropicModels) {
 			const id = modelId as AnthropicModelId
 			return { id, info: anthropicModels[id] }
 		}
+
 		return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] }
 	}
 
@@ -189,14 +215,17 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
 			})
 
 			const content = response.content[0]
+
 			if (content.type === "text") {
 				return content.text
 			}
+
 			return ""
 		} catch (error) {
 			if (error instanceof Error) {
 				throw new Error(`Anthropic completion error: ${error.message}`)
 			}
+
 			throw error
 		}
 	}

+ 1 - 1
src/api/providers/bedrock.ts

@@ -9,7 +9,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
 import { ApiHandler, SingleCompletionHandler } from "../"
 import { ApiHandlerOptions, BedrockModelId, ModelInfo, bedrockDefaultModelId, bedrockModels } from "../../shared/api"
 import { ApiStream } from "../transform/stream"
-import { convertToBedrockConverseMessages, convertToAnthropicMessage } from "../transform/bedrock-converse-format"
+import { convertToBedrockConverseMessages } from "../transform/bedrock-converse-format"
 
 const BEDROCK_DEFAULT_TEMPERATURE = 0.3
 

+ 149 - 232
src/api/transform/__tests__/bedrock-converse-format.test.ts

@@ -1,250 +1,167 @@
-import { convertToBedrockConverseMessages, convertToAnthropicMessage } from "../bedrock-converse-format"
+// npx jest src/api/transform/__tests__/bedrock-converse-format.test.ts
+
+import { convertToBedrockConverseMessages } from "../bedrock-converse-format"
 import { Anthropic } from "@anthropic-ai/sdk"
 import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime"
-import { StreamEvent } from "../../providers/bedrock"
-
-describe("bedrock-converse-format", () => {
-	describe("convertToBedrockConverseMessages", () => {
-		test("converts simple text messages correctly", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{ role: "user", content: "Hello" },
-				{ role: "assistant", content: "Hi there" },
-			]
-
-			const result = convertToBedrockConverseMessages(messages)
-
-			expect(result).toEqual([
-				{
-					role: "user",
-					content: [{ text: "Hello" }],
-				},
-				{
-					role: "assistant",
-					content: [{ text: "Hi there" }],
-				},
-			])
-		})
-
-		test("converts messages with images correctly", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: [
-						{
-							type: "text",
-							text: "Look at this image:",
-						},
-						{
-							type: "image",
-							source: {
-								type: "base64",
-								data: "SGVsbG8=", // "Hello" in base64
-								media_type: "image/jpeg" as const,
-							},
-						},
-					],
-				},
-			]
-
-			const result = convertToBedrockConverseMessages(messages)
-
-			if (!result[0] || !result[0].content) {
-				fail("Expected result to have content")
-				return
-			}
-
-			expect(result[0].role).toBe("user")
-			expect(result[0].content).toHaveLength(2)
-			expect(result[0].content[0]).toEqual({ text: "Look at this image:" })
-
-			const imageBlock = result[0].content[1] as ContentBlock
-			if ("image" in imageBlock && imageBlock.image && imageBlock.image.source) {
-				expect(imageBlock.image.format).toBe("jpeg")
-				expect(imageBlock.image.source).toBeDefined()
-				expect(imageBlock.image.source.bytes).toBeDefined()
-			} else {
-				fail("Expected image block not found")
-			}
-		})
-
-		test("converts tool use messages correctly", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "assistant",
-					content: [
-						{
-							type: "tool_use",
-							id: "test-id",
-							name: "read_file",
-							input: {
-								path: "test.txt",
-							},
-						},
-					],
-				},
-			]
-
-			const result = convertToBedrockConverseMessages(messages)
-
-			if (!result[0] || !result[0].content) {
-				fail("Expected result to have content")
-				return
-			}
-
-			expect(result[0].role).toBe("assistant")
-			const toolBlock = result[0].content[0] as ContentBlock
-			if ("toolUse" in toolBlock && toolBlock.toolUse) {
-				expect(toolBlock.toolUse).toEqual({
-					toolUseId: "test-id",
-					name: "read_file",
-					input: "<read_file>\n<path>\ntest.txt\n</path>\n</read_file>",
-				})
-			} else {
-				fail("Expected tool use block not found")
-			}
-		})
-
-		test("converts tool result messages correctly", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "assistant",
-					content: [
-						{
-							type: "tool_result",
-							tool_use_id: "test-id",
-							content: [{ type: "text", text: "File contents here" }],
-						},
-					],
-				},
-			]
-
-			const result = convertToBedrockConverseMessages(messages)
-
-			if (!result[0] || !result[0].content) {
-				fail("Expected result to have content")
-				return
-			}
-
-			expect(result[0].role).toBe("assistant")
-			const resultBlock = result[0].content[0] as ContentBlock
-			if ("toolResult" in resultBlock && resultBlock.toolResult) {
-				const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }]
-				expect(resultBlock.toolResult).toEqual({
-					toolUseId: "test-id",
-					content: expectedContent,
-					status: "success",
-				})
-			} else {
-				fail("Expected tool result block not found")
-			}
-		})
-
-		test("handles text content correctly", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: [
-						{
-							type: "text",
-							text: "Hello world",
-						},
-					],
-				},
-			]
-
-			const result = convertToBedrockConverseMessages(messages)
-
-			if (!result[0] || !result[0].content) {
-				fail("Expected result to have content")
-				return
-			}
-
-			expect(result[0].role).toBe("user")
-			expect(result[0].content).toHaveLength(1)
-			const textBlock = result[0].content[0] as ContentBlock
-			expect(textBlock).toEqual({ text: "Hello world" })
-		})
+
+describe("convertToBedrockConverseMessages", () => {
+	test("converts simple text messages correctly", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{ role: "user", content: "Hello" },
+			{ role: "assistant", content: "Hi there" },
+		]
+
+		const result = convertToBedrockConverseMessages(messages)
+
+		expect(result).toEqual([
+			{
+				role: "user",
+				content: [{ text: "Hello" }],
+			},
+			{
+				role: "assistant",
+				content: [{ text: "Hi there" }],
+			},
+		])
 	})
 
-	describe("convertToAnthropicMessage", () => {
-		test("converts metadata events correctly", () => {
-			const event: StreamEvent = {
-				metadata: {
-					usage: {
-						inputTokens: 10,
-						outputTokens: 20,
+	test("converts messages with images correctly", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "Look at this image:",
 					},
-				},
-			}
-
-			const result = convertToAnthropicMessage(event, "test-model")
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							data: "SGVsbG8=", // "Hello" in base64
+							media_type: "image/jpeg" as const,
+						},
+					},
+				],
+			},
+		]
+
+		const result = convertToBedrockConverseMessages(messages)
+
+		if (!result[0] || !result[0].content) {
+			fail("Expected result to have content")
+			return
+		}
+
+		expect(result[0].role).toBe("user")
+		expect(result[0].content).toHaveLength(2)
+		expect(result[0].content[0]).toEqual({ text: "Look at this image:" })
+
+		const imageBlock = result[0].content[1] as ContentBlock
+		if ("image" in imageBlock && imageBlock.image && imageBlock.image.source) {
+			expect(imageBlock.image.format).toBe("jpeg")
+			expect(imageBlock.image.source).toBeDefined()
+			expect(imageBlock.image.source.bytes).toBeDefined()
+		} else {
+			fail("Expected image block not found")
+		}
+	})
 
-			expect(result).toEqual({
-				id: "",
-				type: "message",
+	test("converts tool use messages correctly", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
 				role: "assistant",
-				model: "test-model",
-				usage: {
-					input_tokens: 10,
-					output_tokens: 20,
-				},
-			})
-		})
-
-		test("converts content block start events correctly", () => {
-			const event: StreamEvent = {
-				contentBlockStart: {
-					start: {
-						text: "Hello",
+				content: [
+					{
+						type: "tool_use",
+						id: "test-id",
+						name: "read_file",
+						input: {
+							path: "test.txt",
+						},
 					},
-				},
-			}
-
-			const result = convertToAnthropicMessage(event, "test-model")
+				],
+			},
+		]
+
+		const result = convertToBedrockConverseMessages(messages)
+
+		if (!result[0] || !result[0].content) {
+			fail("Expected result to have content")
+			return
+		}
+
+		expect(result[0].role).toBe("assistant")
+		const toolBlock = result[0].content[0] as ContentBlock
+		if ("toolUse" in toolBlock && toolBlock.toolUse) {
+			expect(toolBlock.toolUse).toEqual({
+				toolUseId: "test-id",
+				name: "read_file",
+				input: "<read_file>\n<path>\ntest.txt\n</path>\n</read_file>",
+			})
+		} else {
+			fail("Expected tool use block not found")
+		}
+	})
 
-			expect(result).toEqual({
-				type: "message",
+	test("converts tool result messages correctly", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
 				role: "assistant",
-				content: [{ type: "text", text: "Hello" }],
-				model: "test-model",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "test-id",
+						content: [{ type: "text", text: "File contents here" }],
+					},
+				],
+			},
+		]
+
+		const result = convertToBedrockConverseMessages(messages)
+
+		if (!result[0] || !result[0].content) {
+			fail("Expected result to have content")
+			return
+		}
+
+		expect(result[0].role).toBe("assistant")
+		const resultBlock = result[0].content[0] as ContentBlock
+		if ("toolResult" in resultBlock && resultBlock.toolResult) {
+			const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }]
+			expect(resultBlock.toolResult).toEqual({
+				toolUseId: "test-id",
+				content: expectedContent,
+				status: "success",
 			})
-		})
+		} else {
+			fail("Expected tool result block not found")
+		}
+	})
 
-		test("converts content block delta events correctly", () => {
-			const event: StreamEvent = {
-				contentBlockDelta: {
-					delta: {
-						text: " world",
+	test("handles text content correctly", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "Hello world",
 					},
-				},
-			}
+				],
+			},
+		]
 
-			const result = convertToAnthropicMessage(event, "test-model")
+		const result = convertToBedrockConverseMessages(messages)
 
-			expect(result).toEqual({
-				type: "message",
-				role: "assistant",
-				content: [{ type: "text", text: " world" }],
-				model: "test-model",
-			})
-		})
-
-		test("converts message stop events correctly", () => {
-			const event: StreamEvent = {
-				messageStop: {
-					stopReason: "end_turn" as const,
-				},
-			}
+		if (!result[0] || !result[0].content) {
+			fail("Expected result to have content")
+			return
+		}
 
-			const result = convertToAnthropicMessage(event, "test-model")
-
-			expect(result).toEqual({
-				type: "message",
-				role: "assistant",
-				stop_reason: "end_turn",
-				stop_sequence: null,
-				model: "test-model",
-			})
-		})
+		expect(result[0].role).toBe("user")
+		expect(result[0].content).toHaveLength(1)
+		const textBlock = result[0].content[0] as ContentBlock
+		expect(textBlock).toEqual({ text: "Hello world" })
 	})
 })

+ 338 - 0
src/api/transform/__tests__/gemini-format.test.ts

@@ -0,0 +1,338 @@
+// npx jest src/api/transform/__tests__/gemini-format.test.ts
+
+import { Anthropic } from "@anthropic-ai/sdk"
+
+import { convertAnthropicMessageToGemini } from "../gemini-format"
+
+describe("convertAnthropicMessageToGemini", () => {
+	it("should convert a simple text message", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: "Hello, world!",
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [{ text: "Hello, world!" }],
+		})
+	})
+
+	it("should convert assistant role to model role", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "assistant",
+			content: "I'm an assistant",
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "model",
+			parts: [{ text: "I'm an assistant" }],
+		})
+	})
+
+	it("should convert a message with text blocks", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{ type: "text", text: "First paragraph" },
+				{ type: "text", text: "Second paragraph" },
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [{ text: "First paragraph" }, { text: "Second paragraph" }],
+		})
+	})
+
+	it("should convert a message with an image", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{ type: "text", text: "Check out this image:" },
+				{
+					type: "image",
+					source: {
+						type: "base64",
+						media_type: "image/jpeg",
+						data: "base64encodeddata",
+					},
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [
+				{ text: "Check out this image:" },
+				{
+					inlineData: {
+						data: "base64encodeddata",
+						mimeType: "image/jpeg",
+					},
+				},
+			],
+		})
+	})
+
+	it("should throw an error for unsupported image source type", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{
+					type: "image",
+					source: {
+						type: "url", // Not supported
+						url: "https://example.com/image.jpg",
+					} as any,
+				},
+			],
+		}
+
+		expect(() => convertAnthropicMessageToGemini(anthropicMessage)).toThrow("Unsupported image source type")
+	})
+
+	it("should convert a message with tool use", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "assistant",
+			content: [
+				{ type: "text", text: "Let me calculate that for you." },
+				{
+					type: "tool_use",
+					id: "calc-123",
+					name: "calculator",
+					input: { operation: "add", numbers: [2, 3] },
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "model",
+			parts: [
+				{ text: "Let me calculate that for you." },
+				{
+					functionCall: {
+						name: "calculator",
+						args: { operation: "add", numbers: [2, 3] },
+					},
+				},
+			],
+		})
+	})
+
+	it("should convert a message with tool result as string", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{ type: "text", text: "Here's the result:" },
+				{
+					type: "tool_result",
+					tool_use_id: "calculator-123",
+					content: "The result is 5",
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [
+				{ text: "Here's the result:" },
+				{
+					functionResponse: {
+						name: "calculator",
+						response: {
+							name: "calculator",
+							content: "The result is 5",
+						},
+					},
+				},
+			],
+		})
+	})
+
+	it("should handle empty tool result content", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{
+					type: "tool_result",
+					tool_use_id: "calculator-123",
+					content: null as any, // Empty content
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		// Should skip the empty tool result
+		expect(result).toEqual({
+			role: "user",
+			parts: [],
+		})
+	})
+
+	it("should convert a message with tool result as array with text only", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{
+					type: "tool_result",
+					tool_use_id: "search-123",
+					content: [
+						{ type: "text", text: "First result" },
+						{ type: "text", text: "Second result" },
+					],
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [
+				{
+					functionResponse: {
+						name: "search",
+						response: {
+							name: "search",
+							content: "First result\n\nSecond result",
+						},
+					},
+				},
+			],
+		})
+	})
+
+	it("should convert a message with tool result as array with text and images", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{
+					type: "tool_result",
+					tool_use_id: "search-123",
+					content: [
+						{ type: "text", text: "Search results:" },
+						{
+							type: "image",
+							source: {
+								type: "base64",
+								media_type: "image/png",
+								data: "image1data",
+							},
+						},
+						{
+							type: "image",
+							source: {
+								type: "base64",
+								media_type: "image/jpeg",
+								data: "image2data",
+							},
+						},
+					],
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [
+				{
+					functionResponse: {
+						name: "search",
+						response: {
+							name: "search",
+							content: "Search results:\n\n(See next part for image)",
+						},
+					},
+				},
+				{
+					inlineData: {
+						data: "image1data",
+						mimeType: "image/png",
+					},
+				},
+				{
+					inlineData: {
+						data: "image2data",
+						mimeType: "image/jpeg",
+					},
+				},
+			],
+		})
+	})
+
+	it("should convert a message with tool result containing only images", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{
+					type: "tool_result",
+					tool_use_id: "imagesearch-123",
+					content: [
+						{
+							type: "image",
+							source: {
+								type: "base64",
+								media_type: "image/png",
+								data: "onlyimagedata",
+							},
+						},
+					],
+				},
+			],
+		}
+
+		const result = convertAnthropicMessageToGemini(anthropicMessage)
+
+		expect(result).toEqual({
+			role: "user",
+			parts: [
+				{
+					functionResponse: {
+						name: "imagesearch",
+						response: {
+							name: "imagesearch",
+							content: "\n\n(See next part for image)",
+						},
+					},
+				},
+				{
+					inlineData: {
+						data: "onlyimagedata",
+						mimeType: "image/png",
+					},
+				},
+			],
+		})
+	})
+
+	it("should throw an error for unsupported content block type", () => {
+		const anthropicMessage: Anthropic.Messages.MessageParam = {
+			role: "user",
+			content: [
+				{
+					type: "unknown_type", // Unsupported type
+					data: "some data",
+				} as any,
+			],
+		}
+
+		expect(() => convertAnthropicMessageToGemini(anthropicMessage)).toThrow(
+			"Unsupported content block type: unknown_type",
+		)
+	})
+})

+ 301 - 0
src/api/transform/__tests__/mistral-format.test.ts

@@ -0,0 +1,301 @@
+// npx jest src/api/transform/__tests__/mistral-format.test.ts
+
+import { Anthropic } from "@anthropic-ai/sdk"
+
+import { convertToMistralMessages } from "../mistral-format"
+
+describe("convertToMistralMessages", () => {
+	it("should convert simple text messages for user and assistant roles", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: "Hello",
+			},
+			{
+				role: "assistant",
+				content: "Hi there!",
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(2)
+		expect(mistralMessages[0]).toEqual({
+			role: "user",
+			content: "Hello",
+		})
+		expect(mistralMessages[1]).toEqual({
+			role: "assistant",
+			content: "Hi there!",
+		})
+	})
+
+	it("should handle user messages with image content", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "What is in this image?",
+					},
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/jpeg",
+							data: "base64data",
+						},
+					},
+				],
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(1)
+		expect(mistralMessages[0].role).toBe("user")
+
+		const content = mistralMessages[0].content as Array<{
+			type: string
+			text?: string
+			imageUrl?: { url: string }
+		}>
+
+		expect(Array.isArray(content)).toBe(true)
+		expect(content).toHaveLength(2)
+		expect(content[0]).toEqual({ type: "text", text: "What is in this image?" })
+		expect(content[1]).toEqual({
+			type: "image_url",
+			imageUrl: { url: "data:image/jpeg;base64,base64data" },
+		})
+	})
+
+	it("should handle user messages with only tool results", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "weather-123",
+						content: "Current temperature in London: 20°C",
+					},
+				],
+			},
+		]
+
+		// Based on the implementation, tool results without accompanying text/image
+		// don't generate any messages
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(0)
+	})
+
+	it("should handle user messages with mixed content (text, image, and tool results)", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "Here's the weather data and an image:",
+					},
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/png",
+							data: "imagedata123",
+						},
+					},
+					{
+						type: "tool_result",
+						tool_use_id: "weather-123",
+						content: "Current temperature in London: 20°C",
+					},
+				],
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		// Based on the implementation, only the text and image content is included
+		// Tool results are not converted to separate messages
+		expect(mistralMessages).toHaveLength(1)
+
+		// Message should be the user message with text and image
+		expect(mistralMessages[0].role).toBe("user")
+		const userContent = mistralMessages[0].content as Array<{
+			type: string
+			text?: string
+			imageUrl?: { url: string }
+		}>
+		expect(Array.isArray(userContent)).toBe(true)
+		expect(userContent).toHaveLength(2)
+		expect(userContent[0]).toEqual({ type: "text", text: "Here's the weather data and an image:" })
+		expect(userContent[1]).toEqual({
+			type: "image_url",
+			imageUrl: { url: "data:image/png;base64,imagedata123" },
+		})
+	})
+
+	it("should handle assistant messages with text content", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "text",
+						text: "I'll help you with that question.",
+					},
+				],
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(1)
+		expect(mistralMessages[0].role).toBe("assistant")
+		expect(mistralMessages[0].content).toBe("I'll help you with that question.")
+	})
+
+	it("should handle assistant messages with tool use", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "text",
+						text: "Let me check the weather for you.",
+					},
+					{
+						type: "tool_use",
+						id: "weather-123",
+						name: "get_weather",
+						input: { city: "London" },
+					},
+				],
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(1)
+		expect(mistralMessages[0].role).toBe("assistant")
+		expect(mistralMessages[0].content).toBe("Let me check the weather for you.")
+	})
+
+	it("should handle multiple text blocks in assistant messages", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "text",
+						text: "First paragraph of information.",
+					},
+					{
+						type: "text",
+						text: "Second paragraph with more details.",
+					},
+				],
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(1)
+		expect(mistralMessages[0].role).toBe("assistant")
+		expect(mistralMessages[0].content).toBe("First paragraph of information.\nSecond paragraph with more details.")
+	})
+
+	it("should handle a conversation with mixed message types", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "What's in this image?",
+					},
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/jpeg",
+							data: "imagedata",
+						},
+					},
+				],
+			},
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "text",
+						text: "This image shows a landscape with mountains.",
+					},
+					{
+						type: "tool_use",
+						id: "search-123",
+						name: "search_info",
+						input: { query: "mountain types" },
+					},
+				],
+			},
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "search-123",
+						content: "Found information about different mountain types.",
+					},
+				],
+			},
+			{
+				role: "assistant",
+				content: "Based on the search results, I can tell you more about the mountains in the image.",
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		// Based on the implementation, user messages with only tool results don't generate messages
+		expect(mistralMessages).toHaveLength(3)
+
+		// User message with image
+		expect(mistralMessages[0].role).toBe("user")
+		const userContent = mistralMessages[0].content as Array<{
+			type: string
+			text?: string
+			imageUrl?: { url: string }
+		}>
+		expect(Array.isArray(userContent)).toBe(true)
+		expect(userContent).toHaveLength(2)
+
+		// Assistant message with text (tool_use is not included in Mistral format)
+		expect(mistralMessages[1].role).toBe("assistant")
+		expect(mistralMessages[1].content).toBe("This image shows a landscape with mountains.")
+
+		// Final assistant message
+		expect(mistralMessages[2]).toEqual({
+			role: "assistant",
+			content: "Based on the search results, I can tell you more about the mountains in the image.",
+		})
+	})
+
+	it("should handle empty content in assistant messages", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "tool_use",
+						id: "search-123",
+						name: "search_info",
+						input: { query: "test query" },
+					},
+				],
+			},
+		]
+
+		const mistralMessages = convertToMistralMessages(anthropicMessages)
+		expect(mistralMessages).toHaveLength(1)
+		expect(mistralMessages[0].role).toBe("assistant")
+		expect(mistralMessages[0].content).toBeUndefined()
+	})
+})

+ 101 - 245
src/api/transform/__tests__/openai-format.test.ts

@@ -1,275 +1,131 @@
-import { convertToOpenAiMessages, convertToAnthropicMessage } from "../openai-format"
+// npx jest src/api/transform/__tests__/openai-format.test.ts
+
 import { Anthropic } from "@anthropic-ai/sdk"
 import OpenAI from "openai"
 
-type PartialChatCompletion = Omit<OpenAI.Chat.Completions.ChatCompletion, "choices"> & {
-	choices: Array<
-		Partial<OpenAI.Chat.Completions.ChatCompletion.Choice> & {
-			message: OpenAI.Chat.Completions.ChatCompletion.Choice["message"]
-			finish_reason: string
-			index: number
-		}
-	>
-}
-
-describe("OpenAI Format Transformations", () => {
-	describe("convertToOpenAiMessages", () => {
-		it("should convert simple text messages", () => {
-			const anthropicMessages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: "Hello",
-				},
-				{
-					role: "assistant",
-					content: "Hi there!",
-				},
-			]
+import { convertToOpenAiMessages } from "../openai-format"
 
-			const openAiMessages = convertToOpenAiMessages(anthropicMessages)
-			expect(openAiMessages).toHaveLength(2)
-			expect(openAiMessages[0]).toEqual({
+describe("convertToOpenAiMessages", () => {
+	it("should convert simple text messages", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
 				role: "user",
 				content: "Hello",
-			})
-			expect(openAiMessages[1]).toEqual({
+			},
+			{
 				role: "assistant",
 				content: "Hi there!",
-			})
-		})
-
-		it("should handle messages with image content", () => {
-			const anthropicMessages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: [
-						{
-							type: "text",
-							text: "What is in this image?",
-						},
-						{
-							type: "image",
-							source: {
-								type: "base64",
-								media_type: "image/jpeg",
-								data: "base64data",
-							},
-						},
-					],
-				},
-			]
-
-			const openAiMessages = convertToOpenAiMessages(anthropicMessages)
-			expect(openAiMessages).toHaveLength(1)
-			expect(openAiMessages[0].role).toBe("user")
-
-			const content = openAiMessages[0].content as Array<{
-				type: string
-				text?: string
-				image_url?: { url: string }
-			}>
-
-			expect(Array.isArray(content)).toBe(true)
-			expect(content).toHaveLength(2)
-			expect(content[0]).toEqual({ type: "text", text: "What is in this image?" })
-			expect(content[1]).toEqual({
-				type: "image_url",
-				image_url: { url: "data:image/jpeg;base64,base64data" },
-			})
+			},
+		]
+
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages)
+		expect(openAiMessages).toHaveLength(2)
+		expect(openAiMessages[0]).toEqual({
+			role: "user",
+			content: "Hello",
 		})
-
-		it("should handle assistant messages with tool use", () => {
-			const anthropicMessages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "assistant",
-					content: [
-						{
-							type: "text",
-							text: "Let me check the weather.",
-						},
-						{
-							type: "tool_use",
-							id: "weather-123",
-							name: "get_weather",
-							input: { city: "London" },
-						},
-					],
-				},
-			]
-
-			const openAiMessages = convertToOpenAiMessages(anthropicMessages)
-			expect(openAiMessages).toHaveLength(1)
-
-			const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
-			expect(assistantMessage.role).toBe("assistant")
-			expect(assistantMessage.content).toBe("Let me check the weather.")
-			expect(assistantMessage.tool_calls).toHaveLength(1)
-			expect(assistantMessage.tool_calls![0]).toEqual({
-				id: "weather-123",
-				type: "function",
-				function: {
-					name: "get_weather",
-					arguments: JSON.stringify({ city: "London" }),
-				},
-			})
-		})
-
-		it("should handle user messages with tool results", () => {
-			const anthropicMessages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: [
-						{
-							type: "tool_result",
-							tool_use_id: "weather-123",
-							content: "Current temperature in London: 20°C",
-						},
-					],
-				},
-			]
-
-			const openAiMessages = convertToOpenAiMessages(anthropicMessages)
-			expect(openAiMessages).toHaveLength(1)
-
-			const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
-			expect(toolMessage.role).toBe("tool")
-			expect(toolMessage.tool_call_id).toBe("weather-123")
-			expect(toolMessage.content).toBe("Current temperature in London: 20°C")
+		expect(openAiMessages[1]).toEqual({
+			role: "assistant",
+			content: "Hi there!",
 		})
 	})
 
-	describe("convertToAnthropicMessage", () => {
-		it("should convert simple completion", () => {
-			const openAiCompletion: PartialChatCompletion = {
-				id: "completion-123",
-				model: "gpt-4",
-				choices: [
+	it("should handle messages with image content", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text",
+						text: "What is in this image?",
+					},
 					{
-						message: {
-							role: "assistant",
-							content: "Hello there!",
-							refusal: null,
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/jpeg",
+							data: "base64data",
 						},
-						finish_reason: "stop",
-						index: 0,
 					},
 				],
-				usage: {
-					prompt_tokens: 10,
-					completion_tokens: 5,
-					total_tokens: 15,
-				},
-				created: 123456789,
-				object: "chat.completion",
-			}
-
-			const anthropicMessage = convertToAnthropicMessage(
-				openAiCompletion as OpenAI.Chat.Completions.ChatCompletion,
-			)
-			expect(anthropicMessage.id).toBe("completion-123")
-			expect(anthropicMessage.role).toBe("assistant")
-			expect(anthropicMessage.content).toHaveLength(1)
-			expect(anthropicMessage.content[0]).toEqual({
-				type: "text",
-				text: "Hello there!",
-			})
-			expect(anthropicMessage.stop_reason).toBe("end_turn")
-			expect(anthropicMessage.usage).toEqual({
-				input_tokens: 10,
-				output_tokens: 5,
-			})
+			},
+		]
+
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages)
+		expect(openAiMessages).toHaveLength(1)
+		expect(openAiMessages[0].role).toBe("user")
+
+		const content = openAiMessages[0].content as Array<{
+			type: string
+			text?: string
+			image_url?: { url: string }
+		}>
+
+		expect(Array.isArray(content)).toBe(true)
+		expect(content).toHaveLength(2)
+		expect(content[0]).toEqual({ type: "text", text: "What is in this image?" })
+		expect(content[1]).toEqual({
+			type: "image_url",
+			image_url: { url: "data:image/jpeg;base64,base64data" },
 		})
+	})
 
-		it("should handle tool calls in completion", () => {
-			const openAiCompletion: PartialChatCompletion = {
-				id: "completion-123",
-				model: "gpt-4",
-				choices: [
+	it("should handle assistant messages with tool use", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
 					{
-						message: {
-							role: "assistant",
-							content: "Let me check the weather.",
-							tool_calls: [
-								{
-									id: "weather-123",
-									type: "function",
-									function: {
-										name: "get_weather",
-										arguments: '{"city":"London"}',
-									},
-								},
-							],
-							refusal: null,
-						},
-						finish_reason: "tool_calls",
-						index: 0,
+						type: "text",
+						text: "Let me check the weather.",
+					},
+					{
+						type: "tool_use",
+						id: "weather-123",
+						name: "get_weather",
+						input: { city: "London" },
 					},
 				],
-				usage: {
-					prompt_tokens: 15,
-					completion_tokens: 8,
-					total_tokens: 23,
-				},
-				created: 123456789,
-				object: "chat.completion",
-			}
-
-			const anthropicMessage = convertToAnthropicMessage(
-				openAiCompletion as OpenAI.Chat.Completions.ChatCompletion,
-			)
-			expect(anthropicMessage.content).toHaveLength(2)
-			expect(anthropicMessage.content[0]).toEqual({
-				type: "text",
-				text: "Let me check the weather.",
-			})
-			expect(anthropicMessage.content[1]).toEqual({
-				type: "tool_use",
-				id: "weather-123",
+			},
+		]
+
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages)
+		expect(openAiMessages).toHaveLength(1)
+
+		const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
+		expect(assistantMessage.role).toBe("assistant")
+		expect(assistantMessage.content).toBe("Let me check the weather.")
+		expect(assistantMessage.tool_calls).toHaveLength(1)
+		expect(assistantMessage.tool_calls![0]).toEqual({
+			id: "weather-123",
+			type: "function",
+			function: {
 				name: "get_weather",
-				input: { city: "London" },
-			})
-			expect(anthropicMessage.stop_reason).toBe("tool_use")
+				arguments: JSON.stringify({ city: "London" }),
+			},
 		})
+	})
 
-		it("should handle invalid tool call arguments", () => {
-			const openAiCompletion: PartialChatCompletion = {
-				id: "completion-123",
-				model: "gpt-4",
-				choices: [
+	it("should handle user messages with tool results", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
 					{
-						message: {
-							role: "assistant",
-							content: "Testing invalid arguments",
-							tool_calls: [
-								{
-									id: "test-123",
-									type: "function",
-									function: {
-										name: "test_function",
-										arguments: "invalid json",
-									},
-								},
-							],
-							refusal: null,
-						},
-						finish_reason: "tool_calls",
-						index: 0,
+						type: "tool_result",
+						tool_use_id: "weather-123",
+						content: "Current temperature in London: 20°C",
 					},
 				],
-				created: 123456789,
-				object: "chat.completion",
-			}
+			},
+		]
 
-			const anthropicMessage = convertToAnthropicMessage(
-				openAiCompletion as OpenAI.Chat.Completions.ChatCompletion,
-			)
-			expect(anthropicMessage.content).toHaveLength(2)
-			expect(anthropicMessage.content[1]).toEqual({
-				type: "tool_use",
-				id: "test-123",
-				name: "test_function",
-				input: {}, // Should default to empty object for invalid JSON
-			})
-		})
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages)
+		expect(openAiMessages).toHaveLength(1)
+
+		const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
+		expect(toolMessage.role).toBe("tool")
+		expect(toolMessage.tool_call_id).toBe("weather-123")
+		expect(toolMessage.content).toBe("Current temperature in London: 20°C")
 	})
 })

+ 98 - 162
src/api/transform/__tests__/vscode-lm-format.test.ts

@@ -1,6 +1,8 @@
+// npx jest src/api/transform/__tests__/vscode-lm-format.test.ts
+
 import { Anthropic } from "@anthropic-ai/sdk"
-import * as vscode from "vscode"
-import { convertToVsCodeLmMessages, convertToAnthropicRole, convertToAnthropicMessage } from "../vscode-lm-format"
+
+import { convertToVsCodeLmMessages, convertToAnthropicRole } from "../vscode-lm-format"
 
 // Mock crypto
 const mockCrypto = {
@@ -27,14 +29,6 @@ interface MockLanguageModelToolResultPart {
 	parts: MockLanguageModelTextPart[]
 }
 
-type MockMessageContent = MockLanguageModelTextPart | MockLanguageModelToolCallPart | MockLanguageModelToolResultPart
-
-interface MockLanguageModelChatMessage {
-	role: string
-	name?: string
-	content: MockMessageContent[]
-}
-
 // Mock vscode namespace
 jest.mock("vscode", () => {
 	const LanguageModelChatMessageRole = {
@@ -84,173 +78,115 @@ jest.mock("vscode", () => {
 	}
 })
 
-describe("vscode-lm-format", () => {
-	describe("convertToVsCodeLmMessages", () => {
-		it("should convert simple string messages", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{ role: "user", content: "Hello" },
-				{ role: "assistant", content: "Hi there" },
-			]
-
-			const result = convertToVsCodeLmMessages(messages)
-
-			expect(result).toHaveLength(2)
-			expect(result[0].role).toBe("user")
-			expect((result[0].content[0] as MockLanguageModelTextPart).value).toBe("Hello")
-			expect(result[1].role).toBe("assistant")
-			expect((result[1].content[0] as MockLanguageModelTextPart).value).toBe("Hi there")
-		})
-
-		it("should handle complex user messages with tool results", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: [
-						{ type: "text", text: "Here is the result:" },
-						{
-							type: "tool_result",
-							tool_use_id: "tool-1",
-							content: "Tool output",
-						},
-					],
-				},
-			]
-
-			const result = convertToVsCodeLmMessages(messages)
+describe("convertToVsCodeLmMessages", () => {
+	it("should convert simple string messages", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{ role: "user", content: "Hello" },
+			{ role: "assistant", content: "Hi there" },
+		]
 
-			expect(result).toHaveLength(1)
-			expect(result[0].role).toBe("user")
-			expect(result[0].content).toHaveLength(2)
-			const [toolResult, textContent] = result[0].content as [
-				MockLanguageModelToolResultPart,
-				MockLanguageModelTextPart,
-			]
-			expect(toolResult.type).toBe("tool_result")
-			expect(textContent.type).toBe("text")
-		})
+		const result = convertToVsCodeLmMessages(messages)
 
-		it("should handle complex assistant messages with tool calls", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "assistant",
-					content: [
-						{ type: "text", text: "Let me help you with that." },
-						{
-							type: "tool_use",
-							id: "tool-1",
-							name: "calculator",
-							input: { operation: "add", numbers: [2, 2] },
-						},
-					],
-				},
-			]
-
-			const result = convertToVsCodeLmMessages(messages)
-
-			expect(result).toHaveLength(1)
-			expect(result[0].role).toBe("assistant")
-			expect(result[0].content).toHaveLength(2)
-			const [toolCall, textContent] = result[0].content as [
-				MockLanguageModelToolCallPart,
-				MockLanguageModelTextPart,
-			]
-			expect(toolCall.type).toBe("tool_call")
-			expect(textContent.type).toBe("text")
-		})
-
-		it("should handle image blocks with appropriate placeholders", () => {
-			const messages: Anthropic.Messages.MessageParam[] = [
-				{
-					role: "user",
-					content: [
-						{ type: "text", text: "Look at this:" },
-						{
-							type: "image",
-							source: {
-								type: "base64",
-								media_type: "image/png",
-								data: "base64data",
-							},
-						},
-					],
-				},
-			]
-
-			const result = convertToVsCodeLmMessages(messages)
-
-			expect(result).toHaveLength(1)
-			const imagePlaceholder = result[0].content[1] as MockLanguageModelTextPart
-			expect(imagePlaceholder.value).toContain("[Image (base64): image/png not supported by VSCode LM API]")
-		})
+		expect(result).toHaveLength(2)
+		expect(result[0].role).toBe("user")
+		expect((result[0].content[0] as MockLanguageModelTextPart).value).toBe("Hello")
+		expect(result[1].role).toBe("assistant")
+		expect((result[1].content[0] as MockLanguageModelTextPart).value).toBe("Hi there")
 	})
 
-	describe("convertToAnthropicRole", () => {
-		it("should convert assistant role correctly", () => {
-			const result = convertToAnthropicRole("assistant" as any)
-			expect(result).toBe("assistant")
-		})
-
-		it("should convert user role correctly", () => {
-			const result = convertToAnthropicRole("user" as any)
-			expect(result).toBe("user")
-		})
-
-		it("should return null for unknown roles", () => {
-			const result = convertToAnthropicRole("unknown" as any)
-			expect(result).toBeNull()
-		})
+	it("should handle complex user messages with tool results", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{ type: "text", text: "Here is the result:" },
+					{
+						type: "tool_result",
+						tool_use_id: "tool-1",
+						content: "Tool output",
+					},
+				],
+			},
+		]
+
+		const result = convertToVsCodeLmMessages(messages)
+
+		expect(result).toHaveLength(1)
+		expect(result[0].role).toBe("user")
+		expect(result[0].content).toHaveLength(2)
+		const [toolResult, textContent] = result[0].content as [
+			MockLanguageModelToolResultPart,
+			MockLanguageModelTextPart,
+		]
+		expect(toolResult.type).toBe("tool_result")
+		expect(textContent.type).toBe("text")
 	})
 
-	describe("convertToAnthropicMessage", () => {
-		it("should convert assistant message with text content", async () => {
-			const vsCodeMessage = {
+	it("should handle complex assistant messages with tool calls", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
 				role: "assistant",
-				name: "assistant",
-				content: [new vscode.LanguageModelTextPart("Hello")],
-			}
+				content: [
+					{ type: "text", text: "Let me help you with that." },
+					{
+						type: "tool_use",
+						id: "tool-1",
+						name: "calculator",
+						input: { operation: "add", numbers: [2, 2] },
+					},
+				],
+			},
+		]
 
-			const result = await convertToAnthropicMessage(vsCodeMessage as any)
+		const result = convertToVsCodeLmMessages(messages)
 
-			expect(result.role).toBe("assistant")
-			expect(result.content).toHaveLength(1)
-			expect(result.content[0]).toEqual({
-				type: "text",
-				text: "Hello",
-			})
-			expect(result.id).toBe("test-uuid")
-		})
+		expect(result).toHaveLength(1)
+		expect(result[0].role).toBe("assistant")
+		expect(result[0].content).toHaveLength(2)
+		const [toolCall, textContent] = result[0].content as [MockLanguageModelToolCallPart, MockLanguageModelTextPart]
+		expect(toolCall.type).toBe("tool_call")
+		expect(textContent.type).toBe("text")
+	})
 
-		it("should convert assistant message with tool calls", async () => {
-			const vsCodeMessage = {
-				role: "assistant",
-				name: "assistant",
+	it("should handle image blocks with appropriate placeholders", () => {
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
 				content: [
-					new vscode.LanguageModelToolCallPart("call-1", "calculator", { operation: "add", numbers: [2, 2] }),
+					{ type: "text", text: "Look at this:" },
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/png",
+							data: "base64data",
+						},
+					},
 				],
-			}
+			},
+		]
 
-			const result = await convertToAnthropicMessage(vsCodeMessage as any)
+		const result = convertToVsCodeLmMessages(messages)
 
-			expect(result.content).toHaveLength(1)
-			expect(result.content[0]).toEqual({
-				type: "tool_use",
-				id: "call-1",
-				name: "calculator",
-				input: { operation: "add", numbers: [2, 2] },
-			})
-			expect(result.id).toBe("test-uuid")
-		})
+		expect(result).toHaveLength(1)
+		const imagePlaceholder = result[0].content[1] as MockLanguageModelTextPart
+		expect(imagePlaceholder.value).toContain("[Image (base64): image/png not supported by VSCode LM API]")
+	})
+})
 
-		it("should throw error for non-assistant messages", async () => {
-			const vsCodeMessage = {
-				role: "user",
-				name: "user",
-				content: [new vscode.LanguageModelTextPart("Hello")],
-			}
+describe("convertToAnthropicRole", () => {
+	it("should convert assistant role correctly", () => {
+		const result = convertToAnthropicRole("assistant" as any)
+		expect(result).toBe("assistant")
+	})
+
+	it("should convert user role correctly", () => {
+		const result = convertToAnthropicRole("user" as any)
+		expect(result).toBe("user")
+	})
 
-			await expect(convertToAnthropicMessage(vsCodeMessage as any)).rejects.toThrow(
-				"Roo Code <Language Model API>: Only assistant messages are supported.",
-			)
-		})
+	it("should return null for unknown roles", () => {
+		const result = convertToAnthropicRole("unknown" as any)
+		expect(result).toBeNull()
 	})
 })

+ 1 - 49
src/api/transform/bedrock-converse-format.ts

@@ -1,9 +1,7 @@
 import { Anthropic } from "@anthropic-ai/sdk"
-import { MessageContent } from "../../shared/api"
 import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime"
 
-// Import StreamEvent type from bedrock.ts
-import { StreamEvent } from "../providers/bedrock"
+import { MessageContent } from "../../shared/api"
 
 /**
  * Convert Anthropic messages to Bedrock Converse format
@@ -175,49 +173,3 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
 		}
 	})
 }
-
-/**
- * Convert Bedrock Converse stream events to Anthropic message format
- */
-export function convertToAnthropicMessage(
-	streamEvent: StreamEvent,
-	modelId: string,
-): Partial<Anthropic.Messages.Message> {
-	// Handle metadata events
-	if (streamEvent.metadata?.usage) {
-		return {
-			id: "", // Bedrock doesn't provide message IDs
-			type: "message",
-			role: "assistant",
-			model: modelId,
-			usage: {
-				input_tokens: streamEvent.metadata.usage.inputTokens || 0,
-				output_tokens: streamEvent.metadata.usage.outputTokens || 0,
-			},
-		}
-	}
-
-	// Handle content blocks
-	const text = streamEvent.contentBlockStart?.start?.text || streamEvent.contentBlockDelta?.delta?.text
-	if (text !== undefined) {
-		return {
-			type: "message",
-			role: "assistant",
-			content: [{ type: "text", text: text }],
-			model: modelId,
-		}
-	}
-
-	// Handle message stop
-	if (streamEvent.messageStop) {
-		return {
-			type: "message",
-			role: "assistant",
-			stop_reason: streamEvent.messageStop.stopReason || null,
-			stop_sequence: null,
-			model: modelId,
-		}
-	}
-
-	return {}
-}

+ 3 - 115
src/api/transform/gemini-format.ts

@@ -1,29 +1,11 @@
 import { Anthropic } from "@anthropic-ai/sdk"
-import {
-	Content,
-	EnhancedGenerateContentResponse,
-	FunctionCallPart,
-	FunctionDeclaration,
-	FunctionResponsePart,
-	InlineDataPart,
-	Part,
-	SchemaType,
-	TextPart,
-} from "@google/generative-ai"
+import { Content, FunctionCallPart, FunctionResponsePart, InlineDataPart, Part, TextPart } from "@google/generative-ai"
 
-export function convertAnthropicContentToGemini(
-	content:
-		| string
-		| Array<
-				| Anthropic.Messages.TextBlockParam
-				| Anthropic.Messages.ImageBlockParam
-				| Anthropic.Messages.ToolUseBlockParam
-				| Anthropic.Messages.ToolResultBlockParam
-		  >,
-): Part[] {
+function convertAnthropicContentToGemini(content: Anthropic.Messages.MessageParam["content"]): Part[] {
 	if (typeof content === "string") {
 		return [{ text: content } as TextPart]
 	}
+
 	return content.flatMap((block) => {
 		switch (block.type) {
 			case "text":
@@ -99,97 +81,3 @@ export function convertAnthropicMessageToGemini(message: Anthropic.Messages.Mess
 		parts: convertAnthropicContentToGemini(message.content),
 	}
 }
-
-export function convertAnthropicToolToGemini(tool: Anthropic.Messages.Tool): FunctionDeclaration {
-	return {
-		name: tool.name,
-		description: tool.description || "",
-		parameters: {
-			type: SchemaType.OBJECT,
-			properties: Object.fromEntries(
-				Object.entries(tool.input_schema.properties || {}).map(([key, value]) => [
-					key,
-					{
-						type: (value as any).type.toUpperCase(),
-						description: (value as any).description || "",
-					},
-				]),
-			),
-			required: (tool.input_schema.required as string[]) || [],
-		},
-	}
-}
-
-/*
-It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
-*/
-export function unescapeGeminiContent(content: string) {
-	return content
-		.replace(/\\n/g, "\n")
-		.replace(/\\'/g, "'")
-		.replace(/\\"/g, '"')
-		.replace(/\\r/g, "\r")
-		.replace(/\\t/g, "\t")
-}
-
-export function convertGeminiResponseToAnthropic(
-	response: EnhancedGenerateContentResponse,
-): Anthropic.Messages.Message {
-	const content: Anthropic.Messages.ContentBlock[] = []
-
-	// Add the main text response
-	const text = response.text()
-	if (text) {
-		content.push({ type: "text", text })
-	}
-
-	// Add function calls as tool_use blocks
-	const functionCalls = response.functionCalls()
-	if (functionCalls) {
-		functionCalls.forEach((call, index) => {
-			if ("content" in call.args && typeof call.args.content === "string") {
-				call.args.content = unescapeGeminiContent(call.args.content)
-			}
-			content.push({
-				type: "tool_use",
-				id: `${call.name}-${index}-${Date.now()}`,
-				name: call.name,
-				input: call.args,
-			})
-		})
-	}
-
-	// Determine stop reason
-	let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
-	const finishReason = response.candidates?.[0]?.finishReason
-	if (finishReason) {
-		switch (finishReason) {
-			case "STOP":
-				stop_reason = "end_turn"
-				break
-			case "MAX_TOKENS":
-				stop_reason = "max_tokens"
-				break
-			case "SAFETY":
-			case "RECITATION":
-			case "OTHER":
-				stop_reason = "stop_sequence"
-				break
-			// Add more cases if needed
-		}
-	}
-
-	return {
-		id: `msg_${Date.now()}`, // Generate a unique ID
-		type: "message",
-		role: "assistant",
-		content,
-		model: "",
-		stop_reason,
-		stop_sequence: null, // Gemini doesn't provide this information
-		usage: {
-			input_tokens: response.usageMetadata?.promptTokenCount ?? 0,
-			output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
-		},
-	}
-}

+ 1 - 1
src/api/transform/mistral-format.ts

@@ -1,5 +1,4 @@
 import { Anthropic } from "@anthropic-ai/sdk"
-import { Mistral } from "@mistralai/mistralai"
 import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
 import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
 import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
@@ -13,6 +12,7 @@ export type MistralMessage =
 
 export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
 	const mistralMessages: MistralMessage[] = []
+
 	for (const anthropicMessage of anthropicMessages) {
 		if (typeof anthropicMessage.content === "string") {
 			mistralMessages.push({

+ 0 - 57
src/api/transform/openai-format.ts

@@ -144,60 +144,3 @@ export function convertToOpenAiMessages(
 
 	return openAiMessages
 }
-
-// Convert OpenAI response to Anthropic format
-export function convertToAnthropicMessage(
-	completion: OpenAI.Chat.Completions.ChatCompletion,
-): Anthropic.Messages.Message {
-	const openAiMessage = completion.choices[0].message
-	const anthropicMessage: Anthropic.Messages.Message = {
-		id: completion.id,
-		type: "message",
-		role: openAiMessage.role, // always "assistant"
-		content: [
-			{
-				type: "text",
-				text: openAiMessage.content || "",
-			},
-		],
-		model: completion.model,
-		stop_reason: (() => {
-			switch (completion.choices[0].finish_reason) {
-				case "stop":
-					return "end_turn"
-				case "length":
-					return "max_tokens"
-				case "tool_calls":
-					return "tool_use"
-				case "content_filter": // Anthropic doesn't have an exact equivalent
-				default:
-					return null
-			}
-		})(),
-		stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
-		usage: {
-			input_tokens: completion.usage?.prompt_tokens || 0,
-			output_tokens: completion.usage?.completion_tokens || 0,
-		},
-	}
-
-	if (openAiMessage.tool_calls && openAiMessage.tool_calls.length > 0) {
-		anthropicMessage.content.push(
-			...openAiMessage.tool_calls.map((toolCall): Anthropic.ToolUseBlock => {
-				let parsedInput = {}
-				try {
-					parsedInput = JSON.parse(toolCall.function.arguments || "{}")
-				} catch (error) {
-					console.error("Failed to parse tool arguments:", error)
-				}
-				return {
-					type: "tool_use",
-					id: toolCall.id,
-					name: toolCall.function.name,
-					input: parsedInput,
-				}
-			}),
-		)
-	}
-	return anthropicMessage
-}

+ 1 - 10
src/api/transform/simple-format.ts

@@ -3,16 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
 /**
  * Convert complex content blocks to simple string content
  */
-export function convertToSimpleContent(
-	content:
-		| string
-		| Array<
-				| Anthropic.Messages.TextBlockParam
-				| Anthropic.Messages.ImageBlockParam
-				| Anthropic.Messages.ToolUseBlockParam
-				| Anthropic.Messages.ToolResultBlockParam
-		  >,
-): string {
+export function convertToSimpleContent(content: Anthropic.Messages.MessageParam["content"]): string {
 	if (typeof content === "string") {
 		return content
 	}

+ 0 - 43
src/api/transform/vscode-lm-format.ts

@@ -155,46 +155,3 @@ export function convertToAnthropicRole(vsCodeLmMessageRole: vscode.LanguageModel
 			return null
 	}
 }
-
-export async function convertToAnthropicMessage(
-	vsCodeLmMessage: vscode.LanguageModelChatMessage,
-): Promise<Anthropic.Messages.Message> {
-	const anthropicRole: string | null = convertToAnthropicRole(vsCodeLmMessage.role)
-	if (anthropicRole !== "assistant") {
-		throw new Error("Roo Code <Language Model API>: Only assistant messages are supported.")
-	}
-
-	return {
-		id: crypto.randomUUID(),
-		type: "message",
-		model: "vscode-lm",
-		role: anthropicRole,
-		content: vsCodeLmMessage.content
-			.map((part): Anthropic.ContentBlock | null => {
-				if (part instanceof vscode.LanguageModelTextPart) {
-					return {
-						type: "text",
-						text: part.value,
-					}
-				}
-
-				if (part instanceof vscode.LanguageModelToolCallPart) {
-					return {
-						type: "tool_use",
-						id: part.callId || crypto.randomUUID(),
-						name: part.name,
-						input: asObjectSafe(part.input),
-					}
-				}
-
-				return null
-			})
-			.filter((part): part is Anthropic.ContentBlock => part !== null),
-		stop_reason: null,
-		stop_sequence: null,
-		usage: {
-			input_tokens: 0,
-			output_tokens: 0,
-		},
-	}
-}

+ 1 - 3
src/core/Cline.ts

@@ -69,9 +69,7 @@ const cwd =
 	vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
 
 type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
-type UserContent = Array<
-	Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
->
+type UserContent = Array<Anthropic.Messages.ContentBlockParam>
 
 export type ClineOptions = {
 	provider: ClineProvider

+ 6 - 0
src/core/webview/ClineProvider.ts

@@ -89,6 +89,7 @@ type GlobalStateKey =
 	| "lmStudioModelId"
 	| "lmStudioBaseUrl"
 	| "anthropicBaseUrl"
+	| "anthropicThinking"
 	| "azureApiVersion"
 	| "openAiStreamingEnabled"
 	| "openRouterModelId"
@@ -1654,6 +1655,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			lmStudioModelId,
 			lmStudioBaseUrl,
 			anthropicBaseUrl,
+			anthropicThinking,
 			geminiApiKey,
 			openAiNativeApiKey,
 			deepSeekApiKey,
@@ -1701,6 +1703,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.updateGlobalState("lmStudioModelId", lmStudioModelId),
 			this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
 			this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
+			this.updateGlobalState("anthropicThinking", anthropicThinking),
 			this.storeSecret("geminiApiKey", geminiApiKey),
 			this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
 			this.storeSecret("deepSeekApiKey", deepSeekApiKey),
@@ -2511,6 +2514,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			lmStudioModelId,
 			lmStudioBaseUrl,
 			anthropicBaseUrl,
+			anthropicThinking,
 			geminiApiKey,
 			openAiNativeApiKey,
 			deepSeekApiKey,
@@ -2593,6 +2597,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
 			this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
 			this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
+			this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
 			this.getSecret("geminiApiKey") as Promise<string | undefined>,
 			this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
 			this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
@@ -2692,6 +2697,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				lmStudioModelId,
 				lmStudioBaseUrl,
 				anthropicBaseUrl,
+				anthropicThinking,
 				geminiApiKey,
 				openAiNativeApiKey,
 				deepSeekApiKey,

+ 1 - 8
src/integrations/misc/export-markdown.ts

@@ -41,14 +41,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
 	}
 }
 
-export function formatContentBlockToMarkdown(
-	block:
-		| Anthropic.TextBlockParam
-		| Anthropic.ImageBlockParam
-		| Anthropic.ToolUseBlockParam
-		| Anthropic.ToolResultBlockParam,
-	// messages: Anthropic.MessageParam[]
-): string {
+export function formatContentBlockToMarkdown(block: Anthropic.Messages.ContentBlockParam): string {
 	switch (block.type) {
 		case "text":
 			return block.text

+ 1 - 0
src/shared/__tests__/checkExistApiConfig.test.ts

@@ -32,6 +32,7 @@ describe("checkExistKey", () => {
 			apiKey: "test-key",
 			apiProvider: undefined,
 			anthropicBaseUrl: undefined,
+			anthropicThinking: undefined,
 		}
 		expect(checkExistKey(config)).toBe(true)
 	})

+ 1 - 0
src/shared/api.ts

@@ -21,6 +21,7 @@ export interface ApiHandlerOptions {
 	apiModelId?: string
 	apiKey?: string // anthropic
 	anthropicBaseUrl?: string
+	anthropicThinking?: number
 	vsCodeLmModelSelector?: vscode.LanguageModelChatSelector
 	glamaModelId?: string
 	glamaModelInfo?: ModelInfo

+ 45 - 4
webview-ui/src/components/settings/ApiOptions.tsx

@@ -2,9 +2,10 @@ import { memo, useCallback, useMemo, useState } from "react"
 import { useDebounce, useEvent } from "react-use"
 import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
 import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import { TemperatureControl } from "./TemperatureControl"
 import * as vscodemodels from "vscode"
 
+import { Slider } from "@/components/ui"
+
 import {
 	ApiConfiguration,
 	ModelInfo,
@@ -34,6 +35,7 @@ import {
 	requestyDefaultModelInfo,
 } from "../../../../src/shared/api"
 import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
+
 import { vscode } from "../../utils/vscode"
 import VSCodeButtonLink from "../common/VSCodeButtonLink"
 import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
@@ -43,6 +45,7 @@ import { UnboundModelPicker } from "./UnboundModelPicker"
 import { ModelInfoView } from "./ModelInfoView"
 import { DROPDOWN_Z_INDEX } from "./styles"
 import { RequestyModelPicker } from "./RequestyModelPicker"
+import { TemperatureControl } from "./TemperatureControl"
 
 interface ApiOptionsProps {
 	uriScheme: string | undefined
@@ -65,6 +68,7 @@ const ApiOptions = ({
 	const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
 	const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
 	const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
+	const [anthropicThinkingBudget, setAnthropicThinkingBudget] = useState(apiConfiguration?.anthropicThinking)
 	const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
 	const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
 	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
@@ -185,6 +189,7 @@ const ApiOptions = ({
 						checked={anthropicBaseUrlSelected}
 						onChange={(checked: boolean) => {
 							setAnthropicBaseUrlSelected(checked)
+
 							if (!checked) {
 								setApiConfigurationField("anthropicBaseUrl", "")
 							}
@@ -384,6 +389,7 @@ const ApiOptions = ({
 								checked={openRouterBaseUrlSelected}
 								onChange={(checked: boolean) => {
 									setOpenRouterBaseUrlSelected(checked)
+
 									if (!checked) {
 										setApiConfigurationField("openRouterBaseUrl", "")
 									}
@@ -507,7 +513,7 @@ const ApiOptions = ({
 				</div>
 			)}
 
-			{apiConfiguration?.apiProvider === "vertex" && (
+			{selectedProvider === "vertex" && (
 				<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
 					<VSCodeTextField
 						value={apiConfiguration?.vertexProjectId || ""}
@@ -621,6 +627,7 @@ const ApiOptions = ({
 						checked={azureApiVersionSelected}
 						onChange={(checked: boolean) => {
 							setAzureApiVersionSelected(checked)
+
 							if (!checked) {
 								setApiConfigurationField("azureApiVersion", "")
 							}
@@ -1224,7 +1231,6 @@ const ApiOptions = ({
 			)}
 
 			{selectedProvider === "glama" && <GlamaModelPicker />}
-
 			{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
 			{selectedProvider === "requesty" && <RequestyModelPicker />}
 
@@ -1258,8 +1264,43 @@ const ApiOptions = ({
 					</>
 				)}
 
+			{selectedProvider === "anthropic" && selectedModelId === "claude-3-7-sonnet-20250219" && (
+				<div className="flex flex-col gap-2 mt-2">
+					<Checkbox
+						checked={!!anthropicThinkingBudget}
+						onChange={(checked) => {
+							const budget = checked ? 16_384 : undefined
+							setAnthropicThinkingBudget(budget)
+							setApiConfigurationField("anthropicThinking", budget)
+						}}>
+						Thinking?
+					</Checkbox>
+					{anthropicThinkingBudget && (
+						<>
+							<div className="text-muted-foreground text-sm">
+								Number of tokens Claude is allowed to use for its internal reasoning process.
+							</div>
+							<div className="flex items-center gap-2">
+								<Slider
+									min={1024}
+									max={anthropicModels["claude-3-7-sonnet-20250219"].maxTokens - 1}
+									step={1024}
+									value={[anthropicThinkingBudget]}
+									onValueChange={(value) => {
+										const budget = value[0]
+										setAnthropicThinkingBudget(budget)
+										setApiConfigurationField("anthropicThinking", budget)
+									}}
+								/>
+								<div className="w-10">{anthropicThinkingBudget}</div>
+							</div>
+						</>
+					)}
+				</div>
+			)}
+
 			{!fromWelcomeView && (
-				<div style={{ marginTop: "10px" }}>
+				<div className="mt-2">
 					<TemperatureControl
 						value={apiConfiguration?.modelTemperature}
 						onChange={handleInputChange("modelTemperature", noTransform)}

+ 20 - 23
webview-ui/src/components/settings/SettingsView.tsx

@@ -1,15 +1,7 @@
-import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"
-import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext"
-import { validateApiConfiguration, validateModelId } from "../../utils/validate"
-import { vscode } from "../../utils/vscode"
-import ApiOptions from "./ApiOptions"
-import ExperimentalFeature from "./ExperimentalFeature"
-import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
-import ApiConfigManager from "./ApiConfigManager"
-import { Dropdown } from "vscrui"
-import type { DropdownOption } from "vscrui"
-import { ApiConfiguration } from "../../../../src/shared/api"
+import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import { Dropdown, type DropdownOption } from "vscrui"
+
 import {
 	AlertDialog,
 	AlertDialogContent,
@@ -19,7 +11,17 @@ import {
 	AlertDialogAction,
 	AlertDialogHeader,
 	AlertDialogFooter,
-} from "../ui/alert-dialog"
+} from "@/components/ui"
+
+import { vscode } from "../../utils/vscode"
+import { validateApiConfiguration, validateModelId } from "../../utils/validate"
+import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext"
+import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
+import { ApiConfiguration } from "../../../../src/shared/api"
+
+import ExperimentalFeature from "./ExperimentalFeature"
+import ApiConfigManager from "./ApiConfigManager"
+import ApiOptions from "./ApiOptions"
 
 type SettingsViewProps = {
 	onDone: () => void
@@ -104,7 +106,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 				if (prevState.apiConfiguration?.[field] === value) {
 					return prevState
 				}
+
 				setChangeDetected(true)
+
 				return {
 					...prevState,
 					apiConfiguration: {
@@ -132,6 +136,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 
 	const handleSubmit = () => {
 		const apiValidationResult = validateApiConfiguration(apiConfiguration)
+
 		const modelIdValidationResult = validateModelId(
 			apiConfiguration,
 			extensionState.glamaModels,
@@ -140,6 +145,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 
 		setApiErrorMessage(apiValidationResult)
 		setModelIdErrorMessage(modelIdValidationResult)
+
 		if (!apiValidationResult && !modelIdValidationResult) {
 			vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
 			vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
@@ -162,18 +168,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			vscode.postMessage({ type: "rateLimitSeconds", value: rateLimitSeconds })
 			vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext })
 			vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
-			vscode.postMessage({
-				type: "updateExperimental",
-				values: experiments,
-			})
+			vscode.postMessage({ type: "updateExperimental", values: experiments })
 			vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
-
-			vscode.postMessage({
-				type: "upsertApiConfiguration",
-				text: currentApiConfigName,
-				apiConfiguration,
-			})
-			// onDone()
+			vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
 			setChangeDetected(false)
 		}
 	}

+ 1 - 0
webview-ui/src/components/ui/index.ts

@@ -1,3 +1,4 @@
+export * from "./alert-dialog"
 export * from "./autosize-textarea"
 export * from "./badge"
 export * from "./button"