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
This commit is contained in:
Roo Code 2026-04-29 08:12:28 +00:00
parent ad25634905
commit bea5148503
3 changed files with 399 additions and 1 deletions

View file

@ -22,6 +22,7 @@ import { TelemetryService } from "@roo-code/telemetry"
import type { ApiHandlerOptions } from "../../shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { sanitizeSchemaForGemini } from "../transform/gemini-schema"
import { t } from "i18next"
import type { ApiStream, GroundingSource } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -137,7 +138,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
name: (tool as any).function.name,
description: (tool as any).function.description,
parametersJsonSchema: (tool as any).function.parameters,
parametersJsonSchema: sanitizeSchemaForGemini(
(tool as any).function.parameters as Record<string, unknown>,
),
})),
},
]

View file

@ -0,0 +1,307 @@
// npx vitest run src/api/transform/__tests__/gemini-schema.spec.ts
import { sanitizeSchemaForGemini } from "../gemini-schema"
describe("sanitizeSchemaForGemini", () => {
it("converts type array with null to single type + nullable", () => {
const schema = {
type: "object",
properties: {
cwd: {
type: ["string", "null"],
description: "Working directory",
},
},
required: ["cwd"],
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual({
type: "object",
properties: {
cwd: {
type: "string",
nullable: true,
description: "Working directory",
},
},
required: ["cwd"],
})
})
it("converts type array with number and null", () => {
const schema = {
type: "object",
properties: {
timeout: {
type: ["number", "null"],
description: "Timeout in seconds",
},
},
}
const result = sanitizeSchemaForGemini(schema)
expect(result.properties).toEqual({
timeout: {
type: "number",
nullable: true,
description: "Timeout in seconds",
},
})
})
it("removes additionalProperties", () => {
const schema = {
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
additionalProperties: false,
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual({
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
})
expect(result).not.toHaveProperty("additionalProperties")
})
it("removes nested additionalProperties", () => {
const schema = {
type: "object",
properties: {
indentation: {
type: "object",
properties: {
anchor_line: { type: "integer" },
},
required: [],
additionalProperties: false,
},
},
additionalProperties: false,
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual({
type: "object",
properties: {
indentation: {
type: "object",
properties: {
anchor_line: { type: "integer" },
},
required: [],
},
},
})
})
it("leaves already-valid schemas unchanged (except additionalProperties)", () => {
const schema = {
type: "object",
properties: {
path: { type: "string", description: "File path" },
mode: { type: "string", enum: ["slice", "indentation"] },
},
required: ["path"],
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual(schema)
})
it("handles the full execute_command schema", () => {
const schema = {
type: "object",
properties: {
command: {
type: "string",
description: "Shell command",
},
cwd: {
type: ["string", "null"],
description: "Working directory",
},
timeout: {
type: ["number", "null"],
description: "Timeout",
},
},
required: ["command", "cwd", "timeout"],
additionalProperties: false,
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual({
type: "object",
properties: {
command: {
type: "string",
description: "Shell command",
},
cwd: {
type: "string",
nullable: true,
description: "Working directory",
},
timeout: {
type: "number",
nullable: true,
description: "Timeout",
},
},
required: ["command", "cwd", "timeout"],
})
})
it("handles type array without null", () => {
const schema = {
type: "object",
properties: {
value: {
type: ["string", "number"],
description: "Mixed type",
},
},
}
const result = sanitizeSchemaForGemini(schema)
// Should pick the first non-null type
expect((result.properties as Record<string, Record<string, unknown>>).value.type).toBe("string")
expect((result.properties as Record<string, Record<string, unknown>>).value).not.toHaveProperty("nullable")
})
it("handles empty or null input", () => {
expect(sanitizeSchemaForGemini(null as unknown as Record<string, unknown>)).toBeNull()
expect(sanitizeSchemaForGemini(undefined as unknown as Record<string, unknown>)).toBeUndefined()
})
it("handles single string type (no conversion needed)", () => {
const schema = {
type: "object",
properties: {
name: { type: "string" },
},
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual(schema)
})
it("sanitizes items in array type schemas", () => {
const schema = {
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
text: { type: ["string", "null"] },
},
additionalProperties: false,
},
},
},
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual({
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string", nullable: true },
},
},
},
},
})
})
it("sanitizes anyOf/oneOf/allOf entries", () => {
const schema = {
type: "object",
properties: {
value: {
anyOf: [{ type: "string", additionalProperties: false }, { type: ["number", "null"] }],
},
},
}
const result = sanitizeSchemaForGemini(schema)
expect((result.properties as Record<string, Record<string, unknown>>).value).toEqual({
anyOf: [{ type: "string" }, { type: "number", nullable: true }],
})
})
it("handles the ask_followup_question schema with nested objects", () => {
const schema = {
type: "object",
properties: {
question: { type: "string" },
follow_up: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string" },
mode: {
type: ["string", "null"],
description: "Optional mode",
},
},
required: ["text", "mode"],
additionalProperties: false,
},
},
},
required: ["question", "follow_up"],
additionalProperties: false,
}
const result = sanitizeSchemaForGemini(schema)
expect(result).toEqual({
type: "object",
properties: {
question: { type: "string" },
follow_up: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string" },
mode: {
type: "string",
nullable: true,
description: "Optional mode",
},
},
required: ["text", "mode"],
},
},
},
required: ["question", "follow_up"],
})
})
})

View file

@ -0,0 +1,88 @@
/**
* Sanitizes JSON Schema objects for compatibility with the Gemini API's
* `parametersJsonSchema` field.
*
* The Gemini API does not support certain standard JSON Schema features:
* - `type` as an array (e.g. `["string", "null"]`) for nullable types
* - `additionalProperties` in function parameter schemas
*
* This function recursively converts these constructs into Gemini-compatible
* equivalents:
* - `type: ["string", "null"]` becomes `type: "string", nullable: true`
* - `additionalProperties` is removed
*
* @see https://github.com/RooCodeInc/Roo-Code/issues/12202
*/
export function sanitizeSchemaForGemini(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== "object") {
return schema
}
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(schema)) {
// Remove additionalProperties — Gemini does not support it in
// function declaration schemas.
if (key === "additionalProperties") {
continue
}
// Convert array-type `type` fields to single type + nullable.
// e.g. `type: ["string", "null"]` -> `type: "string", nullable: true`
if (key === "type" && Array.isArray(value)) {
const types = value.filter((t) => t !== "null")
const hasNull = value.includes("null")
if (types.length === 1) {
result.type = types[0]
} else if (types.length > 1) {
// Multiple non-null types — keep the first as a best-effort
// fallback. This shouldn't happen in our tool schemas but
// handles the edge case defensively.
result.type = types[0]
} else {
// All entries were "null" — unusual, default to string.
result.type = "string"
}
if (hasNull) {
result.nullable = true
}
continue
}
// Recursively sanitize nested `properties` objects.
if (key === "properties" && typeof value === "object" && value !== null) {
const sanitizedProps: Record<string, unknown> = {}
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
if (typeof propSchema === "object" && propSchema !== null) {
sanitizedProps[propName] = sanitizeSchemaForGemini(propSchema as Record<string, unknown>)
} else {
sanitizedProps[propName] = propSchema
}
}
result[key] = sanitizedProps
continue
}
// Recursively sanitize `items` for array types.
if (key === "items" && typeof value === "object" && value !== null) {
result[key] = sanitizeSchemaForGemini(value as Record<string, unknown>)
continue
}
// Recursively sanitize `anyOf`, `oneOf`, `allOf` entries.
if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
result[key] = value.map((item) =>
typeof item === "object" && item !== null
? sanitizeSchemaForGemini(item as Record<string, unknown>)
: item,
)
continue
}
result[key] = value
}
return result
}