From 2315ac949fb553ddf7a569c843e21004fc9f61b1 Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Thu, 14 May 2026 14:17:02 -0700 Subject: [PATCH] fix(gemini): preserve tool response names --- src/core/api/providers/aihubmix.ts | 4 +- src/core/api/providers/gemini.ts | 4 +- .../transform/__tests__/gemini-format.test.ts | 83 +++++++++++++++++++ src/core/api/transform/gemini-format.ts | 48 ++++++++++- 4 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 src/core/api/transform/__tests__/gemini-format.test.ts diff --git a/src/core/api/providers/aihubmix.ts b/src/core/api/providers/aihubmix.ts index 76097423b0..87efbf1b37 100644 --- a/src/core/api/providers/aihubmix.ts +++ b/src/core/api/providers/aihubmix.ts @@ -6,7 +6,7 @@ import { buildExternalBasicHeaders } from "@/services/EnvUtils" import { ApiHandler, CommonApiHandlerOptions } from "../index" import { withRetry } from "../retry" import { sanitizeAnthropicMessages } from "../transform/anthropic-format" -import { convertAnthropicMessageToGemini } from "../transform/gemini-format" +import { convertAnthropicMessagesToGemini } from "../transform/gemini-format" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" @@ -285,7 +285,7 @@ export class AIhubmixHandler implements ApiHandler { const client = this.ensureGeminiClient() const modelId = this.options.modelId || "gemini-2.0-flash-exp" - const contents = messages.map(convertAnthropicMessageToGemini) + const contents = convertAnthropicMessagesToGemini(messages) const requestConfig: GenerateContentConfig = { systemInstruction: systemPrompt, diff --git a/src/core/api/providers/gemini.ts b/src/core/api/providers/gemini.ts index 83ccbf0f5b..cedf4f075e 100644 --- a/src/core/api/providers/gemini.ts +++ b/src/core/api/providers/gemini.ts @@ -16,7 +16,7 @@ import { ClineStorageMessage } from "@/shared/messages/content" import { Logger } from "@/shared/services/Logger" import { ApiHandler, CommonApiHandlerOptions } from "../" import { RetriableError, withRetry } from "../retry" -import { convertAnthropicMessageToGemini } from "../transform/gemini-format" +import { convertAnthropicMessagesToGemini } from "../transform/gemini-format" import { ApiStream } from "../transform/stream" const rateLimitPatterns = [/got status: 429/i, /429 Too Many Requests/i, /rate limit exceeded/i, /too many requests/i] @@ -148,7 +148,7 @@ export class GeminiHandler implements ApiHandler { async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: GoogleTool[]): ApiStream { const client = this.ensureClient() const { id: modelId, info } = this.getModel() - const contents = messages.map(convertAnthropicMessageToGemini) + const contents = convertAnthropicMessagesToGemini(messages) // Gemini may emit multiple function calls under the same responseId and without functionCall.id. // Track a local sequence so each emitted tool call has a stable unique ID. const responseToolCallCount = new Map() diff --git a/src/core/api/transform/__tests__/gemini-format.test.ts b/src/core/api/transform/__tests__/gemini-format.test.ts new file mode 100644 index 0000000000..fce1a69938 --- /dev/null +++ b/src/core/api/transform/__tests__/gemini-format.test.ts @@ -0,0 +1,83 @@ +import "should" +import { describe, it } from "mocha" +import { convertAnthropicMessagesToGemini } from "../gemini-format" + +describe("gemini-format", () => { + it("uses the original tool name for function responses and preserves the tool call id", () => { + const contents = convertAnthropicMessagesToGemini([ + { + role: "user", + content: "list files", + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "resp_1-tool-0", + name: "run_commands", + input: { commands: ["ls -la /app"] }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "resp_1-tool-0", + content: "total 0", + }, + ], + }, + ] as any) + + const functionCall = contents[1].parts![0].functionCall! + const functionResponse = contents[2].parts![0].functionResponse! + + functionCall.should.deepEqual({ + id: "resp_1-tool-0", + name: "run_commands", + args: { commands: ["ls -la /app"] }, + }) + functionResponse.should.deepEqual({ + id: "resp_1-tool-0", + name: "run_commands", + response: { + result: "total 0", + }, + }) + }) + + it("can resolve function response names from Cline call_id metadata", () => { + const contents = convertAnthropicMessagesToGemini([ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-use-id", + call_id: "provider-call-id", + name: "read_file", + input: { path: "/tmp/example.txt" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-use-id", + call_id: "provider-call-id", + content: "hello", + }, + ], + }, + ] as any) + + const functionResponse = contents[1].parts![0].functionResponse! + functionResponse.name.should.equal("read_file") + functionResponse.id.should.equal("tool-use-id") + }) +}) diff --git a/src/core/api/transform/gemini-format.ts b/src/core/api/transform/gemini-format.ts index a14f9932e2..55bef2c6ba 100644 --- a/src/core/api/transform/gemini-format.ts +++ b/src/core/api/transform/gemini-format.ts @@ -10,7 +10,32 @@ import { ClineStorageMessage } from "@/shared/messages/content" // "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" in the thought signature field to skip validation. const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator" -export function convertAnthropicContentToGemini(content: string | ClineStorageMessage["content"]): Part[] { +type GeminiToolNameById = Map + +function rememberGeminiToolUse(toolNameById: GeminiToolNameById | undefined, block: Anthropic.ToolUseBlockParam) { + if (!toolNameById) { + return + } + + if (block.id) { + toolNameById.set(block.id, block.name) + } + + const callId = (block as { call_id?: string }).call_id + if (callId) { + toolNameById.set(callId, block.name) + } +} + +function getGeminiFunctionResponseName(block: Anthropic.ToolResultBlockParam, toolNameById: GeminiToolNameById | undefined) { + const callId = (block as { call_id?: string }).call_id + return toolNameById?.get(block.tool_use_id) ?? (callId ? toolNameById?.get(callId) : undefined) ?? block.tool_use_id +} + +export function convertAnthropicContentToGemini( + content: string | ClineStorageMessage["content"], + toolNameById?: GeminiToolNameById, +): Part[] { if (typeof content === "string") { return [{ text: content }] } @@ -30,8 +55,10 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe }, } case "tool_use": + rememberGeminiToolUse(toolNameById, block) return { functionCall: { + id: block.id, name: block.name, args: block.input as Record, }, @@ -39,9 +66,14 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE, } case "tool_result": + const name = getGeminiFunctionResponseName(block, toolNameById) + if (!name) { + throw new Error("Cannot convert Gemini tool result without a matching function name") + } return { functionResponse: { - name: block.tool_use_id, + id: block.tool_use_id, + name, response: { result: block.content, }, @@ -60,13 +92,21 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe .filter((part): part is Part => part !== undefined) // Filter out unsupported blocks } -export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content { +export function convertAnthropicMessageToGemini( + message: Anthropic.Messages.MessageParam, + toolNameById?: GeminiToolNameById, +): Content { return { role: message.role === "assistant" ? "model" : "user", - parts: convertAnthropicContentToGemini(message.content), + parts: convertAnthropicContentToGemini(message.content, toolNameById), } } +export function convertAnthropicMessagesToGemini(messages: Anthropic.Messages.MessageParam[]): Content[] { + const toolNameById: GeminiToolNameById = new Map() + return messages.map((message) => convertAnthropicMessageToGemini(message, toolNameById)) +} + /* 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 */