diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 853d03c1d8b9..0bf27cf79999 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -871,17 +871,262 @@ export namespace ProviderTransform { // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { - const sanitizeGemini = (obj: any): any => { + const sanitizeGemini = (obj: any, rootSchema: any): any => { if (obj === null || typeof obj !== "object") { return obj } if (Array.isArray(obj)) { - return obj.map(sanitizeGemini) + return obj.map((item) => sanitizeGemini(item, rootSchema)) + } + + // Resolve local refs when possible before keyword stripping + if (typeof obj.$ref === "string") { + let resolved: any + if (obj.$ref.startsWith("#/$defs/")) { + const defName = obj.$ref.slice("#/$defs/".length) + resolved = rootSchema?.$defs?.[defName] + } else if (obj.$ref.startsWith("#/definitions/")) { + const defName = obj.$ref.slice("#/definitions/".length) + resolved = rootSchema?.definitions?.[defName] + } + + if (resolved && typeof resolved === "object") { + const merged: any = { ...resolved, ...obj } + delete merged.$ref + return sanitizeGemini(merged, rootSchema) + } + } + + // Merge allOf into a single schema before processing + if (Array.isArray(obj.allOf)) { + const mergedAllOf: any = {} + const mergedRequired = new Set() + let mergedProperties: Record = {} + let hasMergedProperties = false + let mergedItems: any + + for (const variant of obj.allOf) { + if (!variant || typeof variant !== "object") { + continue + } + + if (mergedAllOf.type === undefined && variant.type !== undefined) { + mergedAllOf.type = variant.type + } + + if (mergedAllOf.description === undefined && variant.description !== undefined) { + mergedAllOf.description = variant.description + } + + if (variant.properties && typeof variant.properties === "object" && !Array.isArray(variant.properties)) { + mergedProperties = { + ...mergedProperties, + ...variant.properties, + } + hasMergedProperties = true + } + + if (Array.isArray(variant.required)) { + for (const field of variant.required) { + mergedRequired.add(field) + } + } + + if (variant.items !== undefined) { + if ( + mergedItems && + typeof mergedItems === "object" && + !Array.isArray(mergedItems) && + variant.items && + typeof variant.items === "object" && + !Array.isArray(variant.items) + ) { + mergedItems = { + ...mergedItems, + ...variant.items, + } + } else { + mergedItems = variant.items + } + } + } + + if (hasMergedProperties) { + mergedAllOf.properties = mergedProperties + } + + if (mergedRequired.size > 0) { + mergedAllOf.required = Array.from(mergedRequired) + } + + if (mergedItems !== undefined) { + mergedAllOf.items = mergedItems + } + + const merged: any = { ...mergedAllOf, ...obj } + delete merged.allOf + + if ( + mergedAllOf.properties && + merged.properties && + typeof merged.properties === "object" && + !Array.isArray(merged.properties) + ) { + merged.properties = { + ...mergedAllOf.properties, + ...merged.properties, + } + } + + if (Array.isArray(mergedAllOf.required) || Array.isArray(obj.required)) { + const required = new Set() + if (Array.isArray(mergedAllOf.required)) { + for (const field of mergedAllOf.required) { + required.add(field) + } + } + if (Array.isArray(obj.required)) { + for (const field of obj.required) { + required.add(field) + } + } + merged.required = Array.from(required) + } + + if (mergedAllOf.items !== undefined && obj.items !== undefined) { + if ( + mergedAllOf.items && + typeof mergedAllOf.items === "object" && + !Array.isArray(mergedAllOf.items) && + obj.items && + typeof obj.items === "object" && + !Array.isArray(obj.items) + ) { + merged.items = { + ...mergedAllOf.items, + ...obj.items, + } + } else { + merged.items = obj.items + } + } + + return sanitizeGemini(merged, rootSchema) + } + + // Convert anyOf/oneOf with const values to enum before processing + if (obj.anyOf || obj.oneOf) { + const variants = obj.anyOf || obj.oneOf + if (Array.isArray(variants)) { + const constValues = variants + .filter((v: any) => v && typeof v === "object" && "const" in v) + .map((v: any) => String(v.const)) + if (constValues.length === variants.length && constValues.length > 0) { + const merged: any = { ...obj, type: "string", enum: constValues } + delete merged.anyOf + delete merged.oneOf + delete merged.const + return sanitizeGemini(merged, rootSchema) + } + // Merge object variants when all typed variants are objects + const typeVariants = variants.filter((v: any) => v && typeof v === "object" && v.type) + const objectVariants = typeVariants.filter((v: any) => v.type === "object") + if (objectVariants.length > 0 && objectVariants.length === typeVariants.length) { + const merged: any = { ...obj, type: "object" } + const mergedRequired = new Set() + let mergedProperties: Record = {} + let hasMergedProperties = false + + for (const variant of objectVariants) { + if (variant.properties && typeof variant.properties === "object" && !Array.isArray(variant.properties)) { + mergedProperties = { + ...mergedProperties, + ...variant.properties, + } + hasMergedProperties = true + } + if (Array.isArray(variant.required)) { + for (const field of variant.required) { + mergedRequired.add(field) + } + } + } + + if (hasMergedProperties) { + if (merged.properties && typeof merged.properties === "object" && !Array.isArray(merged.properties)) { + merged.properties = { + ...mergedProperties, + ...merged.properties, + } + } else { + merged.properties = mergedProperties + } + } + + if (mergedRequired.size > 0 || Array.isArray(merged.required)) { + const required = new Set() + if (Array.isArray(merged.required)) { + for (const field of merged.required) { + required.add(field) + } + } + for (const field of mergedRequired) { + required.add(field) + } + merged.required = Array.from(required) + } + + delete merged.anyOf + delete merged.oneOf + return sanitizeGemini(merged, rootSchema) + } + + // If anyOf/oneOf contains mixed type variants, pick the first valid one + if (typeVariants.length > 0) { + const merged: any = { ...obj, ...typeVariants[0] } + delete merged.anyOf + delete merged.oneOf + return sanitizeGemini(merged, rootSchema) + } + } } const result: any = {} for (const [key, value] of Object.entries(obj)) { + // Skip keywords unsupported by Gemini + if ( + key === "additionalProperties" || + key === "$ref" || + key === "$schema" || + key === "$id" || + key === "$defs" || + key === "definitions" || + key === "default" || + key === "const" || + key === "minItems" || + key === "maxItems" || + key === "minLength" || + key === "maxLength" || + key === "pattern" || + key === "patternProperties" || + key === "propertyNames" || + key === "uniqueItems" || + key === "minimum" || + key === "maximum" || + key === "exclusiveMinimum" || + key === "exclusiveMaximum" || + key === "multipleOf" || + key === "if" || + key === "then" || + key === "else" || + key === "not" || + key === "title" || + key === "anyOf" || + key === "oneOf" + ) { + continue + } if (key === "enum" && Array.isArray(value)) { // Convert all enum values to strings result[key] = value.map((v) => String(v)) @@ -890,12 +1135,17 @@ export namespace ProviderTransform { result.type = "string" } } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeGemini(value) + result[key] = sanitizeGemini(value, rootSchema) } else { result[key] = value } } + // Infer type="object" when properties exist but type is missing + if (!result.type && (result.properties || result.required || obj === rootSchema)) { + result.type = "object" + } + // 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) @@ -921,7 +1171,7 @@ export namespace ProviderTransform { return result } - schema = sanitizeGemini(schema) + schema = sanitizeGemini(schema, schema) } return schema as JSONSchema7 diff --git a/packages/opencode/test/provider/sanitize-gemini.test.ts b/packages/opencode/test/provider/sanitize-gemini.test.ts new file mode 100644 index 000000000000..cfa0f60d174b --- /dev/null +++ b/packages/opencode/test/provider/sanitize-gemini.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "../../src/provider/transform" + +const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, +} as any + +describe("ProviderTransform.schema - sanitizeGemini", () => { + test("infers object type for empty root schema", () => { + const result = ProviderTransform.schema(geminiModel, {} as any) as any + expect(result.type).toBe("object") + }) + + test("strips additionalProperties", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.additionalProperties).toBeUndefined() + }) + + test("converts anyOf const variants to string enum", () => { + const schema = { + type: "object", + properties: { + status: { + anyOf: [{ const: 1 }, { const: 2 }, { const: 3 }], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.properties.status.type).toBe("string") + expect(result.properties.status.enum).toEqual(["1", "2", "3"]) + expect(result.properties.status.anyOf).toBeUndefined() + }) + + test("resolves local $ref from $defs and merges sibling fields", () => { + const schema = { + type: "object", + $defs: { + Address: { + type: "object", + properties: { + city: { type: "string" }, + }, + required: ["city"], + additionalProperties: false, + }, + }, + properties: { + shippingAddress: { + $ref: "#/$defs/Address", + description: "Shipping address", + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.$defs).toBeUndefined() + expect(result.properties.shippingAddress.type).toBe("object") + expect(result.properties.shippingAddress.description).toBe("Shipping address") + expect(result.properties.shippingAddress.properties.city.type).toBe("string") + expect(result.properties.shippingAddress.required).toEqual(["city"]) + expect(result.properties.shippingAddress.additionalProperties).toBeUndefined() + }) + + test("merges allOf properties and required fields", () => { + const schema = { + type: "object", + properties: { + profile: { + allOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + { + properties: { + age: { type: "integer" }, + }, + required: ["age"], + }, + ], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.properties.profile.type).toBe("object") + expect(result.properties.profile.properties.name.type).toBe("string") + expect(result.properties.profile.properties.age.type).toBe("integer") + expect([...result.properties.profile.required].sort()).toEqual(["age", "name"]) + expect(result.properties.profile.allOf).toBeUndefined() + }) + + test("sanitizes nested schemas recursively", () => { + const schema = { + type: "object", + properties: { + nested: { + type: "object", + additionalProperties: false, + properties: { + choice: { + anyOf: [{ const: "A" }, { const: "B" }], + }, + external: { + $ref: "https://example.com/schemas/external.json", + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.properties.nested.additionalProperties).toBeUndefined() + expect(result.properties.nested.properties.choice.enum).toEqual(["A", "B"]) + expect(result.properties.nested.properties.choice.anyOf).toBeUndefined() + expect(result.properties.nested.properties.external.$ref).toBeUndefined() + }) + + test("removes object-only keys from non-object types", () => { + const schema = { + type: "object", + properties: { + invalidString: { + type: "string", + properties: { + bad: { type: "string" }, + }, + required: ["bad"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + expect(result.properties.invalidString.type).toBe("string") + expect(result.properties.invalidString.properties).toBeUndefined() + expect(result.properties.invalidString.required).toBeUndefined() + }) +})