Skip to content

Commit b4e50fb

Browse files
committed
feat(gemini): improve native tool calling behavior
1 parent 5428e54 commit b4e50fb

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) {
@@ -207,9 +218,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
207218
yield { type: "reasoning", text: part.text }
208219
}
209220
} else if (part.functionCall) {
221+
const callId = `${part.functionCall.name}-${toolCallCounter++}`
210222
yield {
211223
type: "tool_call",
212-
id: part.functionCall.name, // Gemini doesn't provide call IDs, so we use the function name
224+
id: callId,
213225
name: part.functionCall.name,
214226
arguments: JSON.stringify(part.functionCall.args),
215227
}

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)