diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index a49073ea334..662345e0ed3 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -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, + ), })), }, ] diff --git a/src/api/transform/__tests__/gemini-schema.spec.ts b/src/api/transform/__tests__/gemini-schema.spec.ts new file mode 100644 index 00000000000..c66f4278617 --- /dev/null +++ b/src/api/transform/__tests__/gemini-schema.spec.ts @@ -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>).value.type).toBe("string") + expect((result.properties as Record>).value).not.toHaveProperty("nullable") + }) + + it("handles empty or null input", () => { + expect(sanitizeSchemaForGemini(null as unknown as Record)).toBeNull() + expect(sanitizeSchemaForGemini(undefined as unknown as Record)).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>).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"], + }) + }) +}) diff --git a/src/api/transform/gemini-schema.ts b/src/api/transform/gemini-schema.ts new file mode 100644 index 00000000000..9c0bc377db4 --- /dev/null +++ b/src/api/transform/gemini-schema.ts @@ -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): Record { + if (!schema || typeof schema !== "object") { + return schema + } + + const result: Record = {} + + 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 = {} + for (const [propName, propSchema] of Object.entries(value as Record)) { + if (typeof propSchema === "object" && propSchema !== null) { + sanitizedProps[propName] = sanitizeSchemaForGemini(propSchema as Record) + } 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) + 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) + : item, + ) + continue + } + + result[key] = value + } + + return result +}