Skip to content

Commit dec8fd7

Browse files
committed
feat(gemini): improve native tool calling behavior
1 parent 02d16f0 commit dec8fd7

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
@@ -146,20 +146,29 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
146146
}
147147

148148
if (metadata?.tool_choice) {
149+
const choice = metadata.tool_choice
150+
let mode: FunctionCallingConfigMode
151+
let allowedFunctionNames: string[] | undefined
152+
153+
if (choice === "auto") {
154+
mode = FunctionCallingConfigMode.AUTO
155+
} else if (choice === "none") {
156+
mode = FunctionCallingConfigMode.NONE
157+
} else if (choice === "required") {
158+
// "required" means the model must call at least one tool; Gemini uses ANY for this.
159+
mode = FunctionCallingConfigMode.ANY
160+
} else if (typeof choice === "object" && "function" in choice && choice.type === "function") {
161+
mode = FunctionCallingConfigMode.ANY
162+
allowedFunctionNames = [choice.function.name]
163+
} else {
164+
// Fall back to AUTO for unknown values to avoid unintentionally broadening tool access.
165+
mode = FunctionCallingConfigMode.AUTO
166+
}
167+
149168
config.toolConfig = {
150169
functionCallingConfig: {
151-
mode:
152-
metadata.tool_choice === "auto"
153-
? FunctionCallingConfigMode.AUTO
154-
: metadata.tool_choice === "required"
155-
? FunctionCallingConfigMode.ANY
156-
: metadata.tool_choice === "none"
157-
? FunctionCallingConfigMode.NONE
158-
: FunctionCallingConfigMode.ANY,
159-
allowedFunctionNames:
160-
typeof metadata.tool_choice === "object" && "function" in metadata.tool_choice
161-
? [metadata.tool_choice.function.name]
162-
: undefined,
170+
mode,
171+
...(allowedFunctionNames ? { allowedFunctionNames } : {}),
163172
},
164173
}
165174
}
@@ -172,6 +181,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
172181
let pendingGroundingMetadata: GroundingMetadata | undefined
173182
let finalResponse: { responseId?: string } | undefined
174183

184+
let toolCallCounter = 0
185+
175186
for await (const chunk of result) {
176187
// Track the final structured response (per SDK pattern: candidate.finishReason)
177188
if (chunk.candidates && chunk.candidates[0]?.finishReason) {
@@ -206,9 +217,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
206217
yield { type: "reasoning", text: part.text }
207218
}
208219
} else if (part.functionCall) {
220+
const callId = `${part.functionCall.name}-${toolCallCounter++}`
209221
yield {
210222
type: "tool_call",
211-
id: part.functionCall.name, // Gemini doesn't provide call IDs, so we use the function name
223+
id: callId,
212224
name: part.functionCall.name,
213225
arguments: JSON.stringify(part.functionCall.args),
214226
}

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

Lines changed: 125 additions & 99 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,19 +123,24 @@ 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+
},
139+
thoughtSignature: "skip_thought_signature_validator",
126140
},
127-
thoughtSignature: "skip_thought_signature_validator",
128-
},
129-
],
130-
})
141+
],
142+
},
143+
])
131144
})
132145

133146
it("should convert a message with tool result as string", () => {
@@ -145,21 +158,26 @@ describe("convertAnthropicMessageToGemini", () => {
145158

146159
const result = convertAnthropicMessageToGemini(anthropicMessage)
147160

148-
expect(result).toEqual({
149-
role: "user",
150-
parts: [
151-
{ text: "Here's the result:" },
152-
{
153-
functionResponse: {
154-
name: "calculator",
155-
response: {
161+
expect(result).toEqual([
162+
{
163+
role: "user",
164+
parts: [{ text: "Here's the result:" }],
165+
},
166+
{
167+
role: "user",
168+
parts: [
169+
{
170+
functionResponse: {
156171
name: "calculator",
157-
content: "The result is 5",
172+
response: {
173+
name: "calculator",
174+
content: "The result is 5",
175+
},
158176
},
159177
},
160-
},
161-
],
162-
})
178+
],
179+
},
180+
])
163181
})
164182

165183
it("should handle empty tool result content", () => {
@@ -177,10 +195,12 @@ describe("convertAnthropicMessageToGemini", () => {
177195
const result = convertAnthropicMessageToGemini(anthropicMessage)
178196

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

186206
it("should convert a message with tool result as array with text only", () => {
@@ -200,20 +220,22 @@ describe("convertAnthropicMessageToGemini", () => {
200220

201221
const result = convertAnthropicMessageToGemini(anthropicMessage)
202222

203-
expect(result).toEqual({
204-
role: "user",
205-
parts: [
206-
{
207-
functionResponse: {
208-
name: "search",
209-
response: {
223+
expect(result).toEqual([
224+
{
225+
role: "user",
226+
parts: [
227+
{
228+
functionResponse: {
210229
name: "search",
211-
content: "First result\n\nSecond result",
230+
response: {
231+
name: "search",
232+
content: "First result\n\nSecond result",
233+
},
212234
},
213235
},
214-
},
215-
],
216-
})
236+
],
237+
},
238+
])
217239
})
218240

219241
it("should convert a message with tool result as array with text and images", () => {
@@ -248,32 +270,34 @@ describe("convertAnthropicMessageToGemini", () => {
248270

249271
const result = convertAnthropicMessageToGemini(anthropicMessage)
250272

251-
expect(result).toEqual({
252-
role: "user",
253-
parts: [
254-
{
255-
functionResponse: {
256-
name: "search",
257-
response: {
273+
expect(result).toEqual([
274+
{
275+
role: "user",
276+
parts: [
277+
{
278+
functionResponse: {
258279
name: "search",
259-
content: "Search results:\n\n(See next part for image)",
280+
response: {
281+
name: "search",
282+
content: "Search results:\n\n(See next part for image)",
283+
},
260284
},
261285
},
262-
},
263-
{
264-
inlineData: {
265-
data: "image1data",
266-
mimeType: "image/png",
286+
{
287+
inlineData: {
288+
data: "image1data",
289+
mimeType: "image/png",
290+
},
267291
},
268-
},
269-
{
270-
inlineData: {
271-
data: "image2data",
272-
mimeType: "image/jpeg",
292+
{
293+
inlineData: {
294+
data: "image2data",
295+
mimeType: "image/jpeg",
296+
},
273297
},
274-
},
275-
],
276-
})
298+
],
299+
},
300+
])
277301
})
278302

279303
it("should convert a message with tool result containing only images", () => {
@@ -299,26 +323,28 @@ describe("convertAnthropicMessageToGemini", () => {
299323

300324
const result = convertAnthropicMessageToGemini(anthropicMessage)
301325

302-
expect(result).toEqual({
303-
role: "user",
304-
parts: [
305-
{
306-
functionResponse: {
307-
name: "imagesearch",
308-
response: {
326+
expect(result).toEqual([
327+
{
328+
role: "user",
329+
parts: [
330+
{
331+
functionResponse: {
309332
name: "imagesearch",
310-
content: "\n\n(See next part for image)",
333+
response: {
334+
name: "imagesearch",
335+
content: "\n\n(See next part for image)",
336+
},
311337
},
312338
},
313-
},
314-
{
315-
inlineData: {
316-
data: "onlyimagedata",
317-
mimeType: "image/png",
339+
{
340+
inlineData: {
341+
data: "onlyimagedata",
342+
mimeType: "image/png",
343+
},
318344
},
319-
},
320-
],
321-
})
345+
],
346+
},
347+
])
322348
})
323349

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

src/api/transform/gemini-format.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export function convertAnthropicMessageToGemini(
138138
includeThoughtSignatures: message.role === "assistant" ? options?.includeThoughtSignatures ?? true : false,
139139
}
140140

141+
// Gemini expects a flat list of Content objects. We group regular message parts,
142+
// tool uses, and tool results into separate Content entries while preserving their
143+
// relative order within each category.
141144
const contents: Content[] = []
142145

143146
if (otherParts.length > 0) {

0 commit comments

Comments
 (0)