diff --git a/src/api/transform/__tests__/gemini-format.test.ts b/src/api/transform/__tests__/gemini-format.test.ts new file mode 100644 index 0000000000..fe6b256404 --- /dev/null +++ b/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", + ) + }) +}) diff --git a/src/api/transform/gemini-format.ts b/src/api/transform/gemini-format.ts index 3a149dc149..c8fc80d769 100644 --- a/src/api/transform/gemini-format.ts +++ b/src/api/transform/gemini-format.ts @@ -1,20 +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: Anthropic.Messages.MessageParam["content"]): 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": @@ -90,99 +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, citations: null }) - } - - // 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, - cache_creation_input_tokens: null, - cache_read_input_tokens: null, - }, - } -} diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index f258dd1732..85c2fc7ba5 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -160,6 +160,7 @@ export async function convertToAnthropicMessage( vsCodeLmMessage: vscode.LanguageModelChatMessage, ): Promise { const anthropicRole: string | null = convertToAnthropicRole(vsCodeLmMessage.role) + if (anthropicRole !== "assistant") { throw new Error("Roo Code : Only assistant messages are supported.") }