From 6eca2e3b4edff810521fba8382879d3246665612 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 11:49:56 +0100 Subject: [PATCH 1/5] fix: normalize Gemini schema type arrays --- packages/cli/src/provider/transform.ts | 47 ++++++- packages/cli/test/provider/transform.test.ts | 124 +++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/provider/transform.ts b/packages/cli/src/provider/transform.ts index 67481d3..3e81a00 100644 --- a/packages/cli/src/provider/transform.ts +++ b/packages/cli/src/provider/transform.ts @@ -938,6 +938,31 @@ export namespace ProviderTransform { // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { + const isPlainObject = (node: unknown): node is Record => + typeof node === "object" && node !== null && !Array.isArray(node) + const hasCombiner = (node: unknown) => + isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) + const hasSchemaIntent = (node: unknown) => { + if (!isPlainObject(node)) return false + if (hasCombiner(node)) return true + return [ + "type", + "properties", + "items", + "prefixItems", + "enum", + "const", + "$ref", + "additionalProperties", + "patternProperties", + "required", + "not", + "if", + "then", + "else", + ].some((key) => key in node) + } + const sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { return obj @@ -963,24 +988,38 @@ export namespace ProviderTransform { } } + // Gemini requires a single type rather than a JSON Schema type array. + // Split non-null types into anyOf and express nullability separately. + if (Array.isArray(result.type)) { + const nullable = result.type.includes("null") + const types = result.type.filter((entry: unknown) => entry !== "null") + if (types.length === 0) { + result.type = "null" + } else { + delete result.type + result.anyOf = types.map((entry: unknown) => ({ type: entry })) + if (nullable) result.nullable = true + } + } + // Filter required array to only include fields that exist in properties if (result.type === "object" && result.properties && Array.isArray(result.required)) { result.required = result.required.filter((field: any) => field in result.properties) } - if (result.type === "array") { + if (result.type === "array" && !hasCombiner(result)) { if (result.items == null) { result.items = {} } - // Ensure items has at least a type if it's an empty object + // Ensure items has at least a type if it has no schema keywords // This handles nested arrays like { type: "array", items: { type: "array", items: {} } } - if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) { + if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) { result.items.type = "string" } } // Remove properties/required from non-object types (Gemini rejects these) - if (result.type && result.type !== "object") { + if (result.type && result.type !== "object" && !hasCombiner(result)) { delete result.properties delete result.required } diff --git a/packages/cli/test/provider/transform.test.ts b/packages/cli/test/provider/transform.test.ts index c758136..fa45599 100644 --- a/packages/cli/test/provider/transform.test.ts +++ b/packages/cli/test/provider/transform.test.ts @@ -510,6 +510,130 @@ describe("ProviderTransform.schema - gemini nested array items", () => { }) }) +describe("ProviderTransform.schema - gemini type arrays", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + npm: "@ai-sdk/google", + }, + } as any + + test("splits mixed types into single-type anyOf schemas", () => { + const schema = { + type: "object", + properties: { + status: { type: ["number", "string"], description: "status filter" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.status).toEqual({ + anyOf: [{ type: "number" }, { type: "string" }], + description: "status filter", + }) + }) + + test("lifts null into nullable for Vertex Gemini", () => { + const vertexModel = { + providerID: "google-vertex", + api: { + id: "gemini-2.5-flash", + npm: "@ai-sdk/google-vertex", + }, + } as any + const schema = { + type: "object", + properties: { + query: { type: ["string", "null"] }, + }, + } as any + + const result = ProviderTransform.schema(vertexModel, schema) as any + + expect(result.properties.query).toEqual({ + anyOf: [{ type: "string" }], + nullable: true, + }) + }) + + test("collapses a null-only type array", () => { + const schema = { + type: "object", + properties: { + empty: { type: ["null"] }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.empty).toEqual({ type: "null" }) + }) + + test("preserves generated unions in nested tool parameters", () => { + const schema = { + type: "object", + properties: { + filters: { + type: "array", + items: { type: ["string", "number", "null"] }, + }, + }, + required: ["filters"], + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.filters.items).toEqual({ + anyOf: [{ type: "string" }, { type: "number" }], + nullable: true, + }) + }) + + test("leaves ordinary schemas unchanged", () => { + const schema = { + type: "object", + properties: { + name: { type: "string", description: "display name" }, + }, + required: ["name"], + additionalProperties: false, + } as any + + expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) + }) + + test("leaves type arrays unchanged for non-Gemini models", () => { + const model = { + providerID: "openai", + api: { + id: "gpt-5", + npm: "@ai-sdk/openai", + }, + } as any + const schema = { + type: "object", + properties: { + status: { type: ["number", "string", "null"] }, + }, + } as any + + expect(ProviderTransform.schema(model, schema)).toEqual(schema) + }) + + test("does not add an item type beside an existing combiner", () => { + const schema = { + type: "array", + items: { + anyOf: [{ type: "string" }, { type: "number" }], + }, + } as any + + expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) + }) +}) + describe("ProviderTransform.schema - gemini non-object properties removal", () => { const geminiModel = { providerID: "google", From 0e459de9dc1f1d2e248af7c07601f3e0d0904a3a Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 11:56:14 +0100 Subject: [PATCH 2/5] fix: preserve Gemini schema combiners --- packages/cli/src/provider/transform.ts | 4 +++- packages/cli/test/provider/transform.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/provider/transform.ts b/packages/cli/src/provider/transform.ts index 3e81a00..ac2104d 100644 --- a/packages/cli/src/provider/transform.ts +++ b/packages/cli/src/provider/transform.ts @@ -990,7 +990,9 @@ export namespace ProviderTransform { // Gemini requires a single type rather than a JSON Schema type array. // Split non-null types into anyOf and express nullability separately. - if (Array.isArray(result.type)) { + // Keep composed schemas intact: replacing or layering their combiner can + // discard constraints or change how sibling keywords are evaluated. + if (Array.isArray(result.type) && !hasCombiner(result)) { const nullable = result.type.includes("null") const types = result.type.filter((entry: unknown) => entry !== "null") if (types.length === 0) { diff --git a/packages/cli/test/provider/transform.test.ts b/packages/cli/test/provider/transform.test.ts index fa45599..98eb843 100644 --- a/packages/cli/test/provider/transform.test.ts +++ b/packages/cli/test/provider/transform.test.ts @@ -632,6 +632,21 @@ describe("ProviderTransform.schema - gemini type arrays", () => { expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) }) + + test("preserves a pre-existing anyOf on a type-array node", () => { + const schema = { + type: "object", + properties: { + score: { + type: ["number", "integer"], + anyOf: [{ minimum: 0 }, { maximum: -10 }], + description: "score outside the excluded range", + }, + }, + } as any + + expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) + }) }) describe("ProviderTransform.schema - gemini non-object properties removal", () => { From de24fcbc1a7ccf0bbb1d4b8000a224740a5bf4be Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 12:01:47 +0100 Subject: [PATCH 3/5] fix: defer Gemini unions to native adapters --- packages/cli/src/provider/transform.ts | 5 +++- packages/cli/test/provider/transform.test.ts | 26 +++++++++--------- packages/cli/test/session/llm.test.ts | 29 ++++++++++++++++++-- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/provider/transform.ts b/packages/cli/src/provider/transform.ts index ac2104d..fe50b38 100644 --- a/packages/cli/src/provider/transform.ts +++ b/packages/cli/src/provider/transform.ts @@ -938,6 +938,9 @@ export namespace ProviderTransform { // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { + // Native Google adapters already convert type arrays and preserve nullability. + // Pre-converting them here causes the pinned adapters to drop `nullable`. + const native = model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex" const isPlainObject = (node: unknown): node is Record => typeof node === "object" && node !== null && !Array.isArray(node) const hasCombiner = (node: unknown) => @@ -992,7 +995,7 @@ export namespace ProviderTransform { // Split non-null types into anyOf and express nullability separately. // Keep composed schemas intact: replacing or layering their combiner can // discard constraints or change how sibling keywords are evaluated. - if (Array.isArray(result.type) && !hasCombiner(result)) { + if (Array.isArray(result.type) && !hasCombiner(result) && !native) { const nullable = result.type.includes("null") const types = result.type.filter((entry: unknown) => entry !== "null") if (types.length === 0) { diff --git a/packages/cli/test/provider/transform.test.ts b/packages/cli/test/provider/transform.test.ts index 98eb843..8ce9582 100644 --- a/packages/cli/test/provider/transform.test.ts +++ b/packages/cli/test/provider/transform.test.ts @@ -512,10 +512,10 @@ describe("ProviderTransform.schema - gemini nested array items", () => { describe("ProviderTransform.schema - gemini type arrays", () => { const geminiModel = { - providerID: "google", + providerID: "github-copilot", api: { id: "gemini-3-pro", - npm: "@ai-sdk/google", + npm: "@ai-sdk/github-copilot", }, } as any @@ -535,12 +535,15 @@ describe("ProviderTransform.schema - gemini type arrays", () => { }) }) - test("lifts null into nullable for Vertex Gemini", () => { - const vertexModel = { - providerID: "google-vertex", + test.each([ + ["Google", "google", "@ai-sdk/google"], + ["Vertex", "google-vertex", "@ai-sdk/google-vertex"], + ])("defers nullable type arrays to the native %s adapter", (_, providerID, npm) => { + const model = { + providerID, api: { id: "gemini-2.5-flash", - npm: "@ai-sdk/google-vertex", + npm, }, } as any const schema = { @@ -550,12 +553,9 @@ describe("ProviderTransform.schema - gemini type arrays", () => { }, } as any - const result = ProviderTransform.schema(vertexModel, schema) as any + const result = ProviderTransform.schema(model, schema) as any - expect(result.properties.query).toEqual({ - anyOf: [{ type: "string" }], - nullable: true, - }) + expect(result).toEqual(schema) }) test("collapses a null-only type array", () => { @@ -633,13 +633,13 @@ describe("ProviderTransform.schema - gemini type arrays", () => { expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) }) - test("preserves a pre-existing anyOf on a type-array node", () => { + test.each(["anyOf", "oneOf", "allOf"] as const)("preserves a pre-existing %s on a type-array node", (key) => { const schema = { type: "object", properties: { score: { type: ["number", "integer"], - anyOf: [{ minimum: 0 }, { maximum: -10 }], + [key]: [{ minimum: 0 }, { maximum: -10 }], description: "score outside the excluded range", }, }, diff --git a/packages/cli/test/session/llm.test.ts b/packages/cli/test/session/llm.test.ts index 820d1ad..0f3e3d6 100644 --- a/packages/cli/test/session/llm.test.ts +++ b/packages/cli/test/session/llm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import type { ModelMessage } from "ai" +import { jsonSchema, tool, type ModelMessage } from "ai" import { LLM } from "../../src/session/llm" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" @@ -738,7 +738,21 @@ describe("session.llm.stream", () => { system: ["You are a helpful assistant."], abort: new AbortController().signal, messages: [{ role: "user", content: "Hello" }], - tools: {}, + tools: { + search: tool({ + description: "Search with an optional query", + inputSchema: jsonSchema( + ProviderTransform.schema(resolved, { + type: "object", + properties: { + query: { type: ["string", "null"] }, + }, + required: ["query"], + } as any), + ), + execute: async () => "ok", + }), + }, }) for await (const _ of stream.fullStream) { @@ -754,6 +768,17 @@ describe("session.llm.stream", () => { expect(config?.temperature).toBe(0.3) expect(config?.topP).toBe(0.8) expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved)) + const tools = body.tools as Array<{ + functionDeclarations: Array<{ + name: string + parameters: { properties: { query: Record } } + }> + }> + expect(tools[0].functionDeclarations[0].name).toBe("search") + expect(tools[0].functionDeclarations[0].parameters.properties.query).toEqual({ + anyOf: [{ type: "string" }], + nullable: true, + }) }, }) }) From aff0dd33defd818a9719a747b23ceb4482116ee9 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 22:26:55 +0100 Subject: [PATCH 4/5] fix(provider): preserve Gemini schema members and constraints --- packages/cli/src/provider/transform.ts | 97 ++++++++++++-------- packages/cli/test/provider/transform.test.ts | 92 +++++++++++++++++++ packages/cli/test/session/llm.test.ts | 19 +++- 3 files changed, 167 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/provider/transform.ts b/packages/cli/src/provider/transform.ts index fe50b38..10765d1 100644 --- a/packages/cli/src/provider/transform.ts +++ b/packages/cli/src/provider/transform.ts @@ -945,26 +945,22 @@ export namespace ProviderTransform { typeof node === "object" && node !== null && !Array.isArray(node) const hasCombiner = (node: unknown) => isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) - const hasSchemaIntent = (node: unknown) => { - if (!isPlainObject(node)) return false - if (hasCombiner(node)) return true - return [ - "type", - "properties", - "items", - "prefixItems", - "enum", - "const", - "$ref", - "additionalProperties", - "patternProperties", - "required", - "not", - "if", - "then", - "else", - ].some((key) => key in node) - } + // Default only unconstrained/annotation-only items. An allowlist of + // constraint keywords would miss extensions and silently change intent. + const annotations = new Set([ + "$schema", + "$id", + "$anchor", + "$comment", + "title", + "description", + "default", + "examples", + "example", + "deprecated", + "readOnly", + "writeOnly", + ]) const sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { @@ -991,44 +987,65 @@ export namespace ProviderTransform { } } - // Gemini requires a single type rather than a JSON Schema type array. - // Split non-null types into anyOf and express nullability separately. - // Keep composed schemas intact: replacing or layering their combiner can - // discard constraints or change how sibling keywords are evaluated. - if (Array.isArray(result.type) && !hasCombiner(result) && !native) { - const nullable = result.type.includes("null") - const types = result.type.filter((entry: unknown) => entry !== "null") - if (types.length === 0) { - result.type = "null" - } else { - delete result.type - result.anyOf = types.map((entry: unknown) => ({ type: entry })) - if (nullable) result.nullable = true - } + if (Array.isArray(result.type) && result.type.length === 0) { + throw new Error("Gemini tool schema contains an empty type array") } + const types = Array.isArray(result.type) ? result.type : result.type ? [result.type] : [] + const composed = hasCombiner(result) // Filter required array to only include fields that exist in properties - if (result.type === "object" && result.properties && Array.isArray(result.required)) { + if (types.includes("object") && !composed && result.properties && Array.isArray(result.required)) { result.required = result.required.filter((field: any) => field in result.properties) } - if (result.type === "array" && !hasCombiner(result)) { + if (types.includes("array") && !composed) { if (result.items == null) { result.items = {} } - // Ensure items has at least a type if it has no schema keywords - // This handles nested arrays like { type: "array", items: { type: "array", items: {} } } - if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) { + if ( + isPlainObject(result.items) && + Object.keys(result.items).every( + (key) => annotations.has(key) || (key === "enum" && Array.isArray(result.items.enum)), + ) + ) { + // Empty/annotation-only items retain the existing string default; + // enum-only items have already had their values converted to strings. result.items.type = "string" } } // Remove properties/required from non-object types (Gemini rejects these) - if (result.type && result.type !== "object" && !hasCombiner(result)) { + if (types.length > 0 && !types.includes("object") && !composed) { delete result.properties delete result.required } + // Apply type-specific cleanup before splitting; afterwards type no longer + // identifies whether the union contains object or array members. + // Native adapters own this conversion to preserve their nullability rules. + if (Array.isArray(result.type) && !composed && !native) { + const nullable = types.includes("null") + const nonNull = types.filter((entry: unknown) => entry !== "null") + if (nonNull.length === 0) { + result.type = "null" + } else if (nonNull.length === 1 && !nullable) { + result.type = nonNull[0] + } else { + delete result.type + result.anyOf = nonNull.map((entry: unknown) => ({ + type: entry, + ...(entry === "object" && result.properties !== undefined ? { properties: result.properties } : {}), + ...(entry === "object" && result.required !== undefined ? { required: result.required } : {}), + ...(entry === "array" && result.items !== undefined ? { items: result.items } : {}), + })) + // Member schemas belong to their typed branch, not beside anyOf. + delete result.properties + delete result.required + delete result.items + if (nullable) result.nullable = true + } + } + return result } diff --git a/packages/cli/test/provider/transform.test.ts b/packages/cli/test/provider/transform.test.ts index 8ce9582..424371a 100644 --- a/packages/cli/test/provider/transform.test.ts +++ b/packages/cli/test/provider/transform.test.ts @@ -571,6 +571,98 @@ describe("ProviderTransform.schema - gemini type arrays", () => { expect(result.properties.empty).toEqual({ type: "null" }) }) + test.each(["@ai-sdk/google", "@ai-sdk/google-vertex"])("preserves nullable object members for native %s", (npm) => { + const schema = { + type: "object", + properties: { + options: { type: ["object", "null"], properties: { name: { type: "string" } }, required: ["name"] }, + }, + } as any + expect(ProviderTransform.schema({ ...geminiModel, api: { ...geminiModel.api, npm } }, schema)).toEqual(schema) + }) + + test("strips object-only fields before splitting primitive nullable types", () => { + const schema = { type: ["string", "null"], properties: { unused: { type: "string" } }, required: ["unused"] } as any + expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({ + anyOf: [{ type: "string" }], + nullable: true, + }) + }) + + test("keeps object constraints and filters missing required members before splitting", () => { + const schema = { + type: ["object", "null"], + properties: { name: { type: "string" } }, + required: ["name", "missing"], + } as any + expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({ + anyOf: [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }], + nullable: true, + }) + }) + + test("places object and array members on their matching non-native union branch", () => { + const schema = { + type: ["object", "array", "string", "null"], + properties: { name: { type: "string" } }, + required: ["name"], + items: { enum: ["first", "second"] }, + } as any + expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({ + anyOf: [ + { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + { type: "array", items: { type: "string", enum: ["first", "second"] } }, + { type: "string" }, + ], + nullable: true, + }) + }) + + test.each([ + [ + { type: ["object"], properties: { name: { type: "string" } }, required: ["name"] }, + { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + ], + [ + { type: ["array"], items: { enum: ["first", "second"] } }, + { type: "array", items: { type: "string", enum: ["first", "second"] } }, + ], + [{ type: ["string"] }, { type: "string" }], + ])("collapses a non-null single-type array without a redundant union", (schema, expected) => { + expect(ProviderTransform.schema(geminiModel, schema as any) as unknown).toEqual(expected) + }) + + test("retains explicit string typing for enum-only array items", () => { + const schema = { type: "array", items: { enum: ["a", "b"], description: "allowed values" } } as any + expect(ProviderTransform.schema(geminiModel, schema)).toEqual({ + type: "array", + items: { type: "string", enum: ["a", "b"], description: "allowed values" }, + }) + }) + + test.each([ + { minimum: 0 }, + { maximum: 10 }, + { multipleOf: 2 }, + { minItems: 1 }, + { pattern: "^a" }, + { format: "date-time" }, + { customConstraint: true }, + ])("does not replace constraint-only item intent with a string type", (items) => { + const schema = { type: "array", items } as any + expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) + }) + + test.each(["@ai-sdk/github-copilot", "@ai-sdk/google", "@ai-sdk/google-vertex"])( + "rejects an empty type array before %s converts it", + (npm) => { + const schema = { type: "object", properties: { value: { type: [] } } } as any + expect(() => ProviderTransform.schema({ ...geminiModel, api: { ...geminiModel.api, npm } }, schema)).toThrow( + "empty type array", + ) + }, + ) + test("preserves generated unions in nested tool parameters", () => { const schema = { type: "object", diff --git a/packages/cli/test/session/llm.test.ts b/packages/cli/test/session/llm.test.ts index 0f3e3d6..68a7727 100644 --- a/packages/cli/test/session/llm.test.ts +++ b/packages/cli/test/session/llm.test.ts @@ -746,6 +746,12 @@ describe("session.llm.stream", () => { type: "object", properties: { query: { type: ["string", "null"] }, + options: { + type: ["object", "null"], + properties: { enabled: { type: "boolean" } }, + required: ["enabled"], + }, + choices: { type: ["array", "null"], items: { enum: ["first", "second"] } }, }, required: ["query"], } as any), @@ -771,7 +777,7 @@ describe("session.llm.stream", () => { const tools = body.tools as Array<{ functionDeclarations: Array<{ name: string - parameters: { properties: { query: Record } } + parameters: { properties: Record> } }> }> expect(tools[0].functionDeclarations[0].name).toBe("search") @@ -779,6 +785,17 @@ describe("session.llm.stream", () => { anyOf: [{ type: "string" }], nullable: true, }) + expect(tools[0].functionDeclarations[0].parameters.properties.options).toEqual({ + anyOf: [{ type: "object" }], + nullable: true, + properties: { enabled: { type: "boolean" } }, + required: ["enabled"], + }) + expect(tools[0].functionDeclarations[0].parameters.properties.choices).toEqual({ + anyOf: [{ type: "array" }], + nullable: true, + items: { type: "string", enum: ["first", "second"] }, + }) }, }) }) From c56567746bb2ec37c7b0ed216b7e250c4a2019b5 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Tue, 15 Sep 2026 09:34:46 +0100 Subject: [PATCH 5/5] fix(provider): preserve constraints when splitting Gemini unions --- packages/cli/src/provider/transform.ts | 112 +++++++++++----- packages/cli/test/provider/transform.test.ts | 133 +++++++++++++++++-- packages/cli/test/session/llm.test.ts | 2 + 3 files changed, 201 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/provider/transform.ts b/packages/cli/src/provider/transform.ts index 10765d1..f34670a 100644 --- a/packages/cli/src/provider/transform.ts +++ b/packages/cli/src/provider/transform.ts @@ -9,6 +9,65 @@ import { Flag } from "@/flag/flag" type Modality = NonNullable["input"][number] +const GEMINI_ANNOTATIONS = new Set([ + "$schema", + "$id", + "$anchor", + "$comment", + "title", + "description", + "default", + "examples", + "example", + "deprecated", + "readOnly", + "writeOnly", +]) +const GEMINI_ROOT_KEYS = new Set([...GEMINI_ANNOTATIONS, "$defs", "definitions", "defs"]) +const GEMINI_KEYWORD_TYPES = new Map([ + ...[ + "properties", + "required", + "additionalProperties", + "patternProperties", + "propertyNames", + "minProperties", + "maxProperties", + "dependencies", + "dependentRequired", + "dependentSchemas", + "unevaluatedProperties", + "propertyOrdering", + ].map((key) => [key, ["object"]] as const), + ...[ + "items", + "prefixItems", + "additionalItems", + "contains", + "minContains", + "maxContains", + "minItems", + "maxItems", + "uniqueItems", + "unevaluatedItems", + ].map((key) => [key, ["array"]] as const), + ...["minLength", "maxLength", "pattern", "contentEncoding", "contentMediaType", "contentSchema"].map( + (key) => [key, ["string"]] as const, + ), + ...["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].map( + (key) => [key, ["number", "integer"]] as const, + ), + ["format", ["string", "number", "integer"]], +]) + +function isPlainObject(node: unknown): node is Record { + return typeof node === "object" && node !== null && !Array.isArray(node) +} + +function hasCombiner(node: unknown) { + return isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) +} + function mimeToModality(mime: string): Modality | undefined { if (mime.startsWith("image/")) return "image" if (mime.startsWith("audio/")) return "audio" @@ -941,26 +1000,6 @@ export namespace ProviderTransform { // Native Google adapters already convert type arrays and preserve nullability. // Pre-converting them here causes the pinned adapters to drop `nullable`. const native = model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex" - const isPlainObject = (node: unknown): node is Record => - typeof node === "object" && node !== null && !Array.isArray(node) - const hasCombiner = (node: unknown) => - isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) - // Default only unconstrained/annotation-only items. An allowlist of - // constraint keywords would miss extensions and silently change intent. - const annotations = new Set([ - "$schema", - "$id", - "$anchor", - "$comment", - "title", - "description", - "default", - "examples", - "example", - "deprecated", - "readOnly", - "writeOnly", - ]) const sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { @@ -987,7 +1026,7 @@ export namespace ProviderTransform { } } - if (Array.isArray(result.type) && result.type.length === 0) { + if (!native && Array.isArray(result.type) && result.type.length === 0) { throw new Error("Gemini tool schema contains an empty type array") } const types = Array.isArray(result.type) ? result.type : result.type ? [result.type] : [] @@ -1005,7 +1044,7 @@ export namespace ProviderTransform { if ( isPlainObject(result.items) && Object.keys(result.items).every( - (key) => annotations.has(key) || (key === "enum" && Array.isArray(result.items.enum)), + (key) => GEMINI_ANNOTATIONS.has(key) || (key === "enum" && Array.isArray(result.items.enum)), ) ) { // Empty/annotation-only items retain the existing string default; @@ -1023,7 +1062,7 @@ export namespace ProviderTransform { // Apply type-specific cleanup before splitting; afterwards type no longer // identifies whether the union contains object or array members. // Native adapters own this conversion to preserve their nullability rules. - if (Array.isArray(result.type) && !composed && !native) { + if (Array.isArray(result.type) && !native) { const nullable = types.includes("null") const nonNull = types.filter((entry: unknown) => entry !== "null") if (nonNull.length === 0) { @@ -1031,18 +1070,21 @@ export namespace ProviderTransform { } else if (nonNull.length === 1 && !nullable) { result.type = nonNull[0] } else { - delete result.type - result.anyOf = nonNull.map((entry: unknown) => ({ - type: entry, - ...(entry === "object" && result.properties !== undefined ? { properties: result.properties } : {}), - ...(entry === "object" && result.required !== undefined ? { required: result.required } : {}), - ...(entry === "array" && result.items !== undefined ? { items: result.items } : {}), - })) - // Member schemas belong to their typed branch, not beside anyOf. - delete result.properties - delete result.required - delete result.items - if (nullable) result.nullable = true + const entries = Object.entries(result).filter(([key]) => key !== "type") + const constraints = entries.filter(([key]) => !GEMINI_ROOT_KEYS.has(key)) + // Universal constraints (including existing combiners and enum) can + // exclude null. Keep an explicit constrained null branch in that case. + const constrained = constraints.some(([key]) => !GEMINI_KEYWORD_TYPES.has(key)) + return { + ...Object.fromEntries(entries.filter(([key]) => GEMINI_ROOT_KEYS.has(key))), + anyOf: (constrained ? types : nonNull).map((entry: string) => ({ + type: entry, + ...Object.fromEntries( + constraints.filter(([key]) => GEMINI_KEYWORD_TYPES.get(key)?.includes(entry) ?? true), + ), + })), + ...(!constrained && nullable ? { nullable: true } : {}), + } } } diff --git a/packages/cli/test/provider/transform.test.ts b/packages/cli/test/provider/transform.test.ts index 424371a..1a0f03b 100644 --- a/packages/cli/test/provider/transform.test.ts +++ b/packages/cli/test/provider/transform.test.ts @@ -653,15 +653,15 @@ describe("ProviderTransform.schema - gemini type arrays", () => { expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) }) - test.each(["@ai-sdk/github-copilot", "@ai-sdk/google", "@ai-sdk/google-vertex"])( - "rejects an empty type array before %s converts it", - (npm) => { - const schema = { type: "object", properties: { value: { type: [] } } } as any - expect(() => ProviderTransform.schema({ ...geminiModel, api: { ...geminiModel.api, npm } }, schema)).toThrow( - "empty type array", - ) - }, - ) + test("rejects an empty type array on a non-native transport", () => { + const schema = { type: "object", properties: { value: { type: [] } } } as any + expect(() => ProviderTransform.schema(geminiModel, schema)).toThrow("empty type array") + }) + + test.each(["@ai-sdk/google", "@ai-sdk/google-vertex"])("delegates an empty type array to native %s", (npm) => { + const schema = { type: "object", properties: { value: { type: [] } } } as any + expect(ProviderTransform.schema({ ...geminiModel, api: { ...geminiModel.api, npm } }, schema)).toEqual(schema) + }) test("preserves generated unions in nested tool parameters", () => { const schema = { @@ -725,7 +725,111 @@ describe("ProviderTransform.schema - gemini type arrays", () => { expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) }) - test.each(["anyOf", "oneOf", "allOf"] as const)("preserves a pre-existing %s on a type-array node", (key) => { + test("keeps type-specific constraints on their matching branches", () => { + const schema = { + type: ["object", "array", "string", "number", "null"], + description: "constrained value", + additionalProperties: false, + patternProperties: { "^name": { type: "string" } }, + propertyNames: { pattern: "^name" }, + minProperties: 1, + items: { type: "string" }, + minItems: 1, + maxItems: 3, + uniqueItems: true, + minLength: 1, + pattern: "^name", + minimum: 0, + multipleOf: 2, + } as any + expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({ + description: "constrained value", + anyOf: [ + { + type: "object", + additionalProperties: false, + patternProperties: { "^name": { type: "string" } }, + propertyNames: { pattern: "^name" }, + minProperties: 1, + }, + { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3, uniqueItems: true }, + { type: "string", minLength: 1, pattern: "^name" }, + { type: "number", minimum: 0, multipleOf: 2 }, + ], + nullable: true, + }) + }) + + test("preserves enum restrictions on null as well as non-null branches", () => { + const schema = { type: ["string", "null"], enum: ["allowed"] } as any + expect(ProviderTransform.schema(geminiModel, schema)).toEqual({ + anyOf: [ + { type: "string", enum: ["allowed"] }, + { type: "null", enum: ["allowed"] }, + ], + }) + }) + + test("preserves a combiner's restriction that excludes null", () => { + const constraint = [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }] + const schema = { type: ["object", "null"], anyOf: constraint } as any + expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({ + anyOf: [ + { type: "object", anyOf: constraint }, + { type: "null", anyOf: constraint }, + ], + }) + }) + + test("keeps definitions at their reference location when splitting", () => { + const schema = { + type: ["number", "string"], + definitions: { value: { minimum: 0 } }, + allOf: [{ $ref: "#/definitions/value" }], + } as any + expect(ProviderTransform.schema(geminiModel, schema)).toEqual({ + definitions: { value: { minimum: 0 } }, + anyOf: [ + { type: "number", allOf: [{ $ref: "#/definitions/value" }] }, + { type: "string", allOf: [{ $ref: "#/definitions/value" }] }, + ], + }) + }) + + test("leaves invalid non-array enum input for validation", () => { + const schema = { type: "array", items: { enum: "invalid" } } as any + expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) + }) + + test("does not mutate frozen schemas reused across models or calls", () => { + const schema = { + type: "object", + properties: { + options: { type: ["object", "null"], properties: { name: { type: "string" } }, required: ["name"] }, + choices: { type: ["array", "null"], items: { enum: ["first", "second"] } }, + }, + } as any + const snapshot = structuredClone(schema) + function freeze(value: unknown): void { + if (value === null || typeof value !== "object") return + Object.values(value).forEach(freeze) + Object.freeze(value) + } + freeze(schema) + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(ProviderTransform.schema(geminiModel, schema)).toEqual(result) + const native = { ...geminiModel, api: { ...geminiModel.api, npm: "@ai-sdk/google" } } + const reused = ProviderTransform.schema(native, schema) as any + expect(reused.properties.options.type).toEqual(["object", "null"]) + expect(reused.properties.choices.items).toEqual({ enum: ["first", "second"], type: "string" }) + result.properties.options.anyOf[0].properties.name.type = "number" + result.properties.choices.anyOf[0].items.enum.push("third") + expect(schema).toEqual(snapshot) + expect(reused.properties.options.properties.name.type).toBe("string") + expect(reused.properties.choices.items.enum).toEqual(["first", "second"]) + }) + + test.each(["anyOf", "oneOf", "allOf"] as const)("preserves a pre-existing %s on each typed branch", (key) => { const schema = { type: "object", properties: { @@ -737,7 +841,14 @@ describe("ProviderTransform.schema - gemini type arrays", () => { }, } as any - expect(ProviderTransform.schema(geminiModel, schema)).toEqual(schema) + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.properties.score).toEqual({ + description: "score outside the excluded range", + anyOf: [ + { type: "number", [key]: [{ minimum: 0 }, { maximum: -10 }] }, + { type: "integer", [key]: [{ minimum: 0 }, { maximum: -10 }] }, + ], + }) }) }) diff --git a/packages/cli/test/session/llm.test.ts b/packages/cli/test/session/llm.test.ts index 68a7727..56a17b0 100644 --- a/packages/cli/test/session/llm.test.ts +++ b/packages/cli/test/session/llm.test.ts @@ -746,6 +746,7 @@ describe("session.llm.stream", () => { type: "object", properties: { query: { type: ["string", "null"] }, + empty: { type: ["null"] }, options: { type: ["object", "null"], properties: { enabled: { type: "boolean" } }, @@ -781,6 +782,7 @@ describe("session.llm.stream", () => { }> }> expect(tools[0].functionDeclarations[0].name).toBe("search") + expect(tools[0].functionDeclarations[0].parameters.properties.empty).toEqual({ type: "null" }) expect(tools[0].functionDeclarations[0].parameters.properties.query).toEqual({ anyOf: [{ type: "string" }], nullable: true,