mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
ad25634905
commit
bea5148503
3 changed files with 399 additions and 1 deletions
|
|
@ -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>,
|
||||
),
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
|
|
|||
307
src/api/transform/__tests__/gemini-schema.spec.ts
Normal file
307
src/api/transform/__tests__/gemini-schema.spec.ts
Normal 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"],
|
||||
})
|
||||
})
|
||||
})
|
||||
88
src/api/transform/gemini-schema.ts
Normal file
88
src/api/transform/gemini-schema.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue