Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit bea5148

Browse files
committed
fix: sanitize tool parameter schemas for Gemini API compatibility
The Gemini API does not support certain JSON Schema constructs in parametersJsonSchema for function declarations: - type arrays like ["string", "null"] for nullable types - additionalProperties field This causes 400 INVALID_ARGUMENT errors when the API rejects these schemas. Multiple tool definitions (execute_command, search_files, codebase_search, etc.) use type: ["string", "null"] for optional nullable parameters. Add sanitizeSchemaForGemini() that recursively converts: - type: ["string", "null"] -> type: "string", nullable: true - Removes additionalProperties from all schema levels - Handles nested properties, items, anyOf/oneOf/allOf Fixes #12202
1 parent ad25634 commit bea5148

3 files changed

Lines changed: 399 additions & 1 deletion

File tree

src/api/providers/gemini.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { TelemetryService } from "@roo-code/telemetry"
2222
import type { ApiHandlerOptions } from "../../shared/api"
2323

2424
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
25+
import { sanitizeSchemaForGemini } from "../transform/gemini-schema"
2526
import { t } from "i18next"
2627
import type { ApiStream, GroundingSource } from "../transform/stream"
2728
import { getModelParams } from "../transform/model-params"
@@ -137,7 +138,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
137138
functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
138139
name: (tool as any).function.name,
139140
description: (tool as any).function.description,
140-
parametersJsonSchema: (tool as any).function.parameters,
141+
parametersJsonSchema: sanitizeSchemaForGemini(
142+
(tool as any).function.parameters as Record<string, unknown>,
143+
),
141144
})),
142145
},
143146
]
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// npx vitest run src/api/transform/__tests__/gemini-schema.spec.ts
2+
3+
import { sanitizeSchemaForGemini } from "../gemini-schema"
4+
5+
describe("sanitizeSchemaForGemini", () => {
6+
it("converts type array with null to single type + nullable", () => {
7+
const schema = {
8+
type: "object",
9+
properties: {
10+
cwd: {
11+
type: ["string", "null"],
12+
description: "Working directory",
13+
},
14+
},
15+
required: ["cwd"],
16+
}
17+
18+
const result = sanitizeSchemaForGemini(schema)
19+
20+
expect(result).toEqual({
21+
type: "object",
22+
properties: {
23+
cwd: {
24+
type: "string",
25+
nullable: true,
26+
description: "Working directory",
27+
},
28+
},
29+
required: ["cwd"],
30+
})
31+
})
32+
33+
it("converts type array with number and null", () => {
34+
const schema = {
35+
type: "object",
36+
properties: {
37+
timeout: {
38+
type: ["number", "null"],
39+
description: "Timeout in seconds",
40+
},
41+
},
42+
}
43+
44+
const result = sanitizeSchemaForGemini(schema)
45+
46+
expect(result.properties).toEqual({
47+
timeout: {
48+
type: "number",
49+
nullable: true,
50+
description: "Timeout in seconds",
51+
},
52+
})
53+
})
54+
55+
it("removes additionalProperties", () => {
56+
const schema = {
57+
type: "object",
58+
properties: {
59+
command: { type: "string" },
60+
},
61+
required: ["command"],
62+
additionalProperties: false,
63+
}
64+
65+
const result = sanitizeSchemaForGemini(schema)
66+
67+
expect(result).toEqual({
68+
type: "object",
69+
properties: {
70+
command: { type: "string" },
71+
},
72+
required: ["command"],
73+
})
74+
expect(result).not.toHaveProperty("additionalProperties")
75+
})
76+
77+
it("removes nested additionalProperties", () => {
78+
const schema = {
79+
type: "object",
80+
properties: {
81+
indentation: {
82+
type: "object",
83+
properties: {
84+
anchor_line: { type: "integer" },
85+
},
86+
required: [],
87+
additionalProperties: false,
88+
},
89+
},
90+
additionalProperties: false,
91+
}
92+
93+
const result = sanitizeSchemaForGemini(schema)
94+
95+
expect(result).toEqual({
96+
type: "object",
97+
properties: {
98+
indentation: {
99+
type: "object",
100+
properties: {
101+
anchor_line: { type: "integer" },
102+
},
103+
required: [],
104+
},
105+
},
106+
})
107+
})
108+
109+
it("leaves already-valid schemas unchanged (except additionalProperties)", () => {
110+
const schema = {
111+
type: "object",
112+
properties: {
113+
path: { type: "string", description: "File path" },
114+
mode: { type: "string", enum: ["slice", "indentation"] },
115+
},
116+
required: ["path"],
117+
}
118+
119+
const result = sanitizeSchemaForGemini(schema)
120+
121+
expect(result).toEqual(schema)
122+
})
123+
124+
it("handles the full execute_command schema", () => {
125+
const schema = {
126+
type: "object",
127+
properties: {
128+
command: {
129+
type: "string",
130+
description: "Shell command",
131+
},
132+
cwd: {
133+
type: ["string", "null"],
134+
description: "Working directory",
135+
},
136+
timeout: {
137+
type: ["number", "null"],
138+
description: "Timeout",
139+
},
140+
},
141+
required: ["command", "cwd", "timeout"],
142+
additionalProperties: false,
143+
}
144+
145+
const result = sanitizeSchemaForGemini(schema)
146+
147+
expect(result).toEqual({
148+
type: "object",
149+
properties: {
150+
command: {
151+
type: "string",
152+
description: "Shell command",
153+
},
154+
cwd: {
155+
type: "string",
156+
nullable: true,
157+
description: "Working directory",
158+
},
159+
timeout: {
160+
type: "number",
161+
nullable: true,
162+
description: "Timeout",
163+
},
164+
},
165+
required: ["command", "cwd", "timeout"],
166+
})
167+
})
168+
169+
it("handles type array without null", () => {
170+
const schema = {
171+
type: "object",
172+
properties: {
173+
value: {
174+
type: ["string", "number"],
175+
description: "Mixed type",
176+
},
177+
},
178+
}
179+
180+
const result = sanitizeSchemaForGemini(schema)
181+
182+
// Should pick the first non-null type
183+
expect((result.properties as Record<string, Record<string, unknown>>).value.type).toBe("string")
184+
expect((result.properties as Record<string, Record<string, unknown>>).value).not.toHaveProperty("nullable")
185+
})
186+
187+
it("handles empty or null input", () => {
188+
expect(sanitizeSchemaForGemini(null as unknown as Record<string, unknown>)).toBeNull()
189+
expect(sanitizeSchemaForGemini(undefined as unknown as Record<string, unknown>)).toBeUndefined()
190+
})
191+
192+
it("handles single string type (no conversion needed)", () => {
193+
const schema = {
194+
type: "object",
195+
properties: {
196+
name: { type: "string" },
197+
},
198+
}
199+
200+
const result = sanitizeSchemaForGemini(schema)
201+
202+
expect(result).toEqual(schema)
203+
})
204+
205+
it("sanitizes items in array type schemas", () => {
206+
const schema = {
207+
type: "object",
208+
properties: {
209+
items: {
210+
type: "array",
211+
items: {
212+
type: "object",
213+
properties: {
214+
text: { type: ["string", "null"] },
215+
},
216+
additionalProperties: false,
217+
},
218+
},
219+
},
220+
}
221+
222+
const result = sanitizeSchemaForGemini(schema)
223+
224+
expect(result).toEqual({
225+
type: "object",
226+
properties: {
227+
items: {
228+
type: "array",
229+
items: {
230+
type: "object",
231+
properties: {
232+
text: { type: "string", nullable: true },
233+
},
234+
},
235+
},
236+
},
237+
})
238+
})
239+
240+
it("sanitizes anyOf/oneOf/allOf entries", () => {
241+
const schema = {
242+
type: "object",
243+
properties: {
244+
value: {
245+
anyOf: [{ type: "string", additionalProperties: false }, { type: ["number", "null"] }],
246+
},
247+
},
248+
}
249+
250+
const result = sanitizeSchemaForGemini(schema)
251+
252+
expect((result.properties as Record<string, Record<string, unknown>>).value).toEqual({
253+
anyOf: [{ type: "string" }, { type: "number", nullable: true }],
254+
})
255+
})
256+
257+
it("handles the ask_followup_question schema with nested objects", () => {
258+
const schema = {
259+
type: "object",
260+
properties: {
261+
question: { type: "string" },
262+
follow_up: {
263+
type: "array",
264+
items: {
265+
type: "object",
266+
properties: {
267+
text: { type: "string" },
268+
mode: {
269+
type: ["string", "null"],
270+
description: "Optional mode",
271+
},
272+
},
273+
required: ["text", "mode"],
274+
additionalProperties: false,
275+
},
276+
},
277+
},
278+
required: ["question", "follow_up"],
279+
additionalProperties: false,
280+
}
281+
282+
const result = sanitizeSchemaForGemini(schema)
283+
284+
expect(result).toEqual({
285+
type: "object",
286+
properties: {
287+
question: { type: "string" },
288+
follow_up: {
289+
type: "array",
290+
items: {
291+
type: "object",
292+
properties: {
293+
text: { type: "string" },
294+
mode: {
295+
type: "string",
296+
nullable: true,
297+
description: "Optional mode",
298+
},
299+
},
300+
required: ["text", "mode"],
301+
},
302+
},
303+
},
304+
required: ["question", "follow_up"],
305+
})
306+
})
307+
})

0 commit comments

Comments
 (0)