Compare commits

...
Author SHA1 Message Date
Arafatkatze 2315ac949f fix(gemini): preserve tool response names 2026-05-14 14:30:21 -07:00
4 changed files with 131 additions and 8 deletions
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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<string, number>()
@@ -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")
})
})
+44 -4
View File
@@ -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<string, string>
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<string, unknown>,
},
@@ -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
*/