diff --git a/packages/ai/src/protocols/utils/gemini-tool-schema.ts b/packages/ai/src/protocols/utils/gemini-tool-schema.ts index 991dfdfbf96c..a5790f35fdfb 100644 --- a/packages/ai/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/ai/src/protocols/utils/gemini-tool-schema.ts @@ -74,7 +74,10 @@ const projectNode = (schema: unknown, nested = false): Record | ["description", schema.description], ["required", schema.required], ["format", schema.format], - ["type", types ? (types.length === 0 ? "null" : undefined) : schema.type], + // A single non-null type stays a plain `type` so an optional object keeps carrying its + // `properties` and `required`; Gemini reads those only on an OBJECT-typed node and rejects + // the whole request when the type is expressed as a combiner instead. + ["type", types ? (types.length === 0 ? "null" : types.length === 1 ? types[0] : undefined) : schema.type], [ "nullable", (Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf @@ -103,7 +106,7 @@ const projectNode = (schema: unknown, nested = false): Record | ? hasNullAnyOf && anyOfTypes.length === 1 ? undefined : anyOfTypes.map((item) => projectNode(item, true)) - : types && types.length > 0 + : types && types.length > 1 ? types.map((type) => ({ type })) : undefined, ], diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 42d7754a5906..f48a38430f09 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -465,8 +465,8 @@ describe("Gemini route", () => { anyOf: [{ type: "number" }, { type: "string" }], }, maybe: { + type: "string", nullable: true, - anyOf: [{ type: "string" }], }, nothing: { type: "null", diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index 4f83942a6608..47233180d181 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -50,6 +50,36 @@ describe("tool schema projections", () => { }) }) + test("gemini keeps an optional object typed so its properties survive", () => { + // Shape an MCP server emits for an optional object parameter. + expect( + ToolSchemaProjection.gemini({ + type: "object", + properties: { + cursor_theme: { + type: ["object", "null"], + properties: { theme_id: { type: "string" } }, + required: ["theme_id"], + }, + note: { type: ["string", "null"] }, + either: { type: ["string", "number"] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + cursor_theme: { + type: "object", + nullable: true, + required: ["theme_id"], + properties: { theme_id: { type: "string" } }, + }, + note: { type: "string", nullable: true }, + either: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + }) + }) + test("openai keeps one flat object top-level schema", () => { expect( ToolSchemaProjection.openAI({