Skip to content

Commit bb1973d

Browse files
committed
feat(gemini): improve native tool calling behavior
1 parent 88b3f74 commit bb1973d

File tree

3 files changed

+153
-112
lines changed

3 files changed

+153
-112
lines changed

src/api/providers/gemini.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,29 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
137137
}
138138

139139
if (metadata?.tool_choice) {
140+
const choice = metadata.tool_choice
141+
let mode: FunctionCallingConfigMode
142+
let allowedFunctionNames: string[] | undefined
143+
144+
if (choice === "auto") {
145+
mode = FunctionCallingConfigMode.AUTO
146+
} else if (choice === "none") {
147+
mode = FunctionCallingConfigMode.NONE
148+
} else if (choice === "required") {
149+
// "required" means the model must call at least one tool; Gemini uses ANY for this.
150+
mode = FunctionCallingConfigMode.ANY
151+
} else if (typeof choice === "object" && "function" in choice && choice.type === "function") {
152+
mode = FunctionCallingConfigMode.ANY
153+
allowedFunctionNames = [choice.function.name]
154+
} else {
155+
// Fall back to AUTO for unknown values to avoid unintentionally broadening tool access.
156+
mode = FunctionCallingConfigMode.AUTO
157+
}
158+
140159
config.toolConfig = {
141160
functionCallingConfig: {
142-
mode:
143-
metadata.tool_choice === "auto"
144-
? FunctionCallingConfigMode.AUTO
145-
: metadata.tool_choice === "required"
146-
? FunctionCallingConfigMode.ANY
147-
: metadata.tool_choice === "none"
148-
? FunctionCallingConfigMode.NONE
149-
: FunctionCallingConfigMode.ANY,
150-
allowedFunctionNames:
151-
typeof metadata.tool_choice === "object" && "function" in metadata.tool_choice
152-
? [metadata.tool_choice.function.name]
153-
: undefined,
161+
mode,
162+
...(allowedFunctionNames ? { allowedFunctionNames } : {}),
154163
},
155164
}
156165
}
@@ -164,6 +173,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
164173
let pendingGroundingMetadata: GroundingMetadata | undefined
165174
let finalResponse: { responseId?: string } | undefined
166175

176+
let toolCallCounter = 0
177+
167178
for await (const chunk of result) {
168179
// Track the final structured response (per SDK pattern: candidate.finishReason)
169180
if (chunk.candidates && chunk.candidates[0]?.finishReason) {
@@ -199,9 +210,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
199210
yield { type: "reasoning", text: part.text }
200211
}
201212
} else if (part.functionCall) {
213+
const callId = `${part.functionCall.name}-${toolCallCounter++}`
202214
yield {
203215
type: "tool_call",
204-
id: part.functionCall.name, // Gemini doesn't provide call IDs, so we use the function name
216+
id: callId,
205217
name: part.functionCall.name,
206218
arguments: JSON.stringify(part.functionCall.args),
207219
}

src/api/transform/__tests__/gemini-format.spec.ts

Lines changed: 124 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ describe("convertAnthropicMessageToGemini", () => {
1313

1414
const result = convertAnthropicMessageToGemini(anthropicMessage)
1515

16-
expect(result).toEqual({
17-
role: "user",
18-
parts: [{ text: "Hello, world!" }],
19-
})
16+
expect(result).toEqual([
17+
{
18+
role: "user",
19+
parts: [{ text: "Hello, world!" }],
20+
},
21+
])
2022
})
2123

2224
it("should convert assistant role to model role", () => {
@@ -27,10 +29,12 @@ describe("convertAnthropicMessageToGemini", () => {
2729

2830
const result = convertAnthropicMessageToGemini(anthropicMessage)
2931

30-
expect(result).toEqual({
31-
role: "model",
32-
parts: [{ text: "I'm an assistant" }],
33-
})
32+
expect(result).toEqual([
33+
{
34+
role: "model",
35+
parts: [{ text: "I'm an assistant" }],
36+
},
37+
])
3438
})
3539

3640
it("should convert a message with text blocks", () => {
@@ -44,10 +48,12 @@ describe("convertAnthropicMessageToGemini", () => {
4448

4549
const result = convertAnthropicMessageToGemini(anthropicMessage)
4650

47-
expect(result).toEqual({
48-
role: "user",
49-
parts: [{ text: "First paragraph" }, { text: "Second paragraph" }],
50-
})
51+
expect(result).toEqual([
52+
{
53+
role: "user",
54+
parts: [{ text: "First paragraph" }, { text: "Second paragraph" }],
55+
},
56+
])
5157
})
5258

5359
it("should convert a message with an image", () => {
@@ -68,18 +74,20 @@ describe("convertAnthropicMessageToGemini", () => {
6874

6975
const result = convertAnthropicMessageToGemini(anthropicMessage)
7076

71-
expect(result).toEqual({
72-
role: "user",
73-
parts: [
74-
{ text: "Check out this image:" },
75-
{
76-
inlineData: {
77-
data: "base64encodeddata",
78-
mimeType: "image/jpeg",
77+
expect(result).toEqual([
78+
{
79+
role: "user",
80+
parts: [
81+
{ text: "Check out this image:" },
82+
{
83+
inlineData: {
84+
data: "base64encodeddata",
85+
mimeType: "image/jpeg",
86+
},
7987
},
80-
},
81-
],
82-
})
88+
],
89+
},
90+
])
8391
})
8492

8593
it("should throw an error for unsupported image source type", () => {
@@ -115,18 +123,23 @@ describe("convertAnthropicMessageToGemini", () => {
115123

116124
const result = convertAnthropicMessageToGemini(anthropicMessage)
117125

118-
expect(result).toEqual({
119-
role: "model",
120-
parts: [
121-
{ text: "Let me calculate that for you." },
122-
{
123-
functionCall: {
124-
name: "calculator",
125-
args: { operation: "add", numbers: [2, 3] },
126+
expect(result).toEqual([
127+
{
128+
role: "model",
129+
parts: [{ text: "Let me calculate that for you." }],
130+
},
131+
{
132+
role: "model",
133+
parts: [
134+
{
135+
functionCall: {
136+
name: "calculator",
137+
args: { operation: "add", numbers: [2, 3] },
138+
},
126139
},
127-
},
128-
],
129-
})
140+
],
141+
},
142+
])
130143
})
131144

132145
it("should convert a message with tool result as string", () => {
@@ -144,21 +157,26 @@ describe("convertAnthropicMessageToGemini", () => {
144157

145158
const result = convertAnthropicMessageToGemini(anthropicMessage)
146159

147-
expect(result).toEqual({
148-
role: "user",
149-
parts: [
150-
{ text: "Here's the result:" },
151-
{
152-
functionResponse: {
153-
name: "calculator",
154-
response: {
160+
expect(result).toEqual([
161+
{
162+
role: "user",
163+
parts: [{ text: "Here's the result:" }],
164+
},
165+
{
166+
role: "user",
167+
parts: [
168+
{
169+
functionResponse: {
155170
name: "calculator",
156-
content: "The result is 5",
171+
response: {
172+
name: "calculator",
173+
content: "The result is 5",
174+
},
157175
},
158176
},
159-
},
160-
],
161-
})
177+
],
178+
},
179+
])
162180
})
163181

164182
it("should handle empty tool result content", () => {
@@ -176,10 +194,12 @@ describe("convertAnthropicMessageToGemini", () => {
176194
const result = convertAnthropicMessageToGemini(anthropicMessage)
177195

178196
// Should skip the empty tool result
179-
expect(result).toEqual({
180-
role: "user",
181-
parts: [],
182-
})
197+
expect(result).toEqual([
198+
{
199+
role: "user",
200+
parts: [],
201+
},
202+
])
183203
})
184204

185205
it("should convert a message with tool result as array with text only", () => {
@@ -199,20 +219,22 @@ describe("convertAnthropicMessageToGemini", () => {
199219

200220
const result = convertAnthropicMessageToGemini(anthropicMessage)
201221

202-
expect(result).toEqual({
203-
role: "user",
204-
parts: [
205-
{
206-
functionResponse: {
207-
name: "search",
208-
response: {
222+
expect(result).toEqual([
223+
{
224+
role: "user",
225+
parts: [
226+
{
227+
functionResponse: {
209228
name: "search",
210-
content: "First result\n\nSecond result",
229+
response: {
230+
name: "search",
231+
content: "First result\n\nSecond result",
232+
},
211233
},
212234
},
213-
},
214-
],
215-
})
235+
],
236+
},
237+
])
216238
})
217239

218240
it("should convert a message with tool result as array with text and images", () => {
@@ -247,32 +269,34 @@ describe("convertAnthropicMessageToGemini", () => {
247269

248270
const result = convertAnthropicMessageToGemini(anthropicMessage)
249271

250-
expect(result).toEqual({
251-
role: "user",
252-
parts: [
253-
{
254-
functionResponse: {
255-
name: "search",
256-
response: {
272+
expect(result).toEqual([
273+
{
274+
role: "user",
275+
parts: [
276+
{
277+
functionResponse: {
257278
name: "search",
258-
content: "Search results:\n\n(See next part for image)",
279+
response: {
280+
name: "search",
281+
content: "Search results:\n\n(See next part for image)",
282+
},
259283
},
260284
},
261-
},
262-
{
263-
inlineData: {
264-
data: "image1data",
265-
mimeType: "image/png",
285+
{
286+
inlineData: {
287+
data: "image1data",
288+
mimeType: "image/png",
289+
},
266290
},
267-
},
268-
{
269-
inlineData: {
270-
data: "image2data",
271-
mimeType: "image/jpeg",
291+
{
292+
inlineData: {
293+
data: "image2data",
294+
mimeType: "image/jpeg",
295+
},
272296
},
273-
},
274-
],
275-
})
297+
],
298+
},
299+
])
276300
})
277301

278302
it("should convert a message with tool result containing only images", () => {
@@ -298,26 +322,28 @@ describe("convertAnthropicMessageToGemini", () => {
298322

299323
const result = convertAnthropicMessageToGemini(anthropicMessage)
300324

301-
expect(result).toEqual({
302-
role: "user",
303-
parts: [
304-
{
305-
functionResponse: {
306-
name: "imagesearch",
307-
response: {
325+
expect(result).toEqual([
326+
{
327+
role: "user",
328+
parts: [
329+
{
330+
functionResponse: {
308331
name: "imagesearch",
309-
content: "\n\n(See next part for image)",
332+
response: {
333+
name: "imagesearch",
334+
content: "\n\n(See next part for image)",
335+
},
310336
},
311337
},
312-
},
313-
{
314-
inlineData: {
315-
data: "onlyimagedata",
316-
mimeType: "image/png",
338+
{
339+
inlineData: {
340+
data: "onlyimagedata",
341+
mimeType: "image/png",
342+
},
317343
},
318-
},
319-
],
320-
})
344+
],
345+
},
346+
])
321347
})
322348

323349
it("should throw an error for unsupported content block type", () => {

src/api/transform/gemini-format.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function convertAnthropicContentToGemini(
105105
export function convertAnthropicMessageToGemini(
106106
message: Anthropic.Messages.MessageParam,
107107
options?: { includeThoughtSignatures?: boolean },
108-
): Content | Content[] {
108+
): Content[] {
109109
const content = Array.isArray(message.content) ? message.content : [{ type: "text", text: message.content ?? "" }]
110110
const toolUseParts = content.filter((block) => block.type === "tool_use") as Anthropic.ToolUseBlock[]
111111
const toolResultParts = content.filter((block) => block.type === "tool_result") as Anthropic.ToolResultBlockParam[]
@@ -114,6 +114,9 @@ export function convertAnthropicMessageToGemini(
114114
| Anthropic.ImageBlockParam
115115
)[]
116116

117+
// Gemini expects a flat list of Content objects. We group regular message parts,
118+
// tool uses, and tool results into separate Content entries while preserving their
119+
// relative order within each category.
117120
const contents: Content[] = []
118121

119122
if (otherParts.length > 0) {

0 commit comments

Comments
 (0)