diff --git a/packages/cli/src/provider/transform.ts b/packages/cli/src/provider/transform.ts index 67481d3..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" @@ -938,6 +997,10 @@ 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 sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { return obj @@ -963,28 +1026,68 @@ export namespace ProviderTransform { } } + 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] : [] + 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") { + if (types.includes("array") && !composed) { if (result.items == null) { result.items = {} } - // Ensure items has at least a type if it's an empty object - // 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) && + Object.keys(result.items).every( + (key) => GEMINI_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") { + 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) && !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 { + 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 } : {}), + } + } + } + return result } diff --git a/packages/cli/test/provider/transform.test.ts b/packages/cli/test/provider/transform.test.ts index c758136..1a0f03b 100644 --- a/packages/cli/test/provider/transform.test.ts +++ b/packages/cli/test/provider/transform.test.ts @@ -510,6 +510,348 @@ describe("ProviderTransform.schema - gemini nested array items", () => { }) }) +describe("ProviderTransform.schema - gemini type arrays", () => { + const geminiModel = { + providerID: "github-copilot", + api: { + id: "gemini-3-pro", + npm: "@ai-sdk/github-copilot", + }, + } 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.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, + }, + } as any + const schema = { + type: "object", + properties: { + query: { type: ["string", "null"] }, + }, + } as any + + const result = ProviderTransform.schema(model, schema) as any + + expect(result).toEqual(schema) + }) + + 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.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("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 = { + 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) + }) + + 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: { + score: { + type: ["number", "integer"], + [key]: [{ minimum: 0 }, { maximum: -10 }], + description: "score outside the excluded range", + }, + }, + } as any + + 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 }] }, + ], + }) + }) +}) + describe("ProviderTransform.schema - gemini non-object properties removal", () => { const geminiModel = { providerID: "google", diff --git a/packages/cli/test/session/llm.test.ts b/packages/cli/test/session/llm.test.ts index 820d1ad..56a17b0 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,28 @@ 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"] }, + empty: { type: ["null"] }, + options: { + type: ["object", "null"], + properties: { enabled: { type: "boolean" } }, + required: ["enabled"], + }, + choices: { type: ["array", "null"], items: { enum: ["first", "second"] } }, + }, + required: ["query"], + } as any), + ), + execute: async () => "ok", + }), + }, }) for await (const _ of stream.fullStream) { @@ -754,6 +775,29 @@ 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: Record> } + }> + }> + 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, + }) + 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"] }, + }) }, }) })