From 7e3e85ba596b8fd837bc61410b4d224908486918 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 3 Mar 2026 10:11:05 +0530 Subject: [PATCH] fix(opencode): avoid gemini combiner schema sibling injection (#15318) --- packages/opencode/src/provider/transform.ts | 35 +++++- .../opencode/test/provider/transform.test.ts | 100 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b659799c1..4be3035ab 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -897,6 +897,32 @@ 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 @@ -927,19 +953,18 @@ export namespace ProviderTransform { 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 - // This handles nested arrays like { type: "array", items: { type: "array", items: {} } } - if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) { + // Ensure items has a type only when it's still schema-empty. + 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/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 189bdfd32..232984635 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -510,6 +510,106 @@ describe("ProviderTransform.schema - gemini nested array items", () => { }) }) +describe("ProviderTransform.schema - gemini combiner nodes", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + const walk = (node: any, cb: (node: any, path: (string | number)[]) => void, path: (string | number)[] = []) => { + if (node === null || typeof node !== "object") { + return + } + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, cb, [...path, i])) + return + } + cb(node, path) + Object.entries(node).forEach(([key, value]) => walk(value, cb, [...path, key])) + } + + test("keeps edits.items.anyOf without adding type", () => { + const schema = { + type: "object", + properties: { + edits: { + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + old_string: { type: "string" }, + new_string: { type: "string" }, + }, + required: ["old_string", "new_string"], + }, + { + type: "object", + properties: { + old_string: { type: "string" }, + new_string: { type: "string" }, + replace_all: { type: "boolean" }, + }, + required: ["old_string", "new_string"], + }, + ], + }, + }, + }, + required: ["edits"], + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(Array.isArray(result.properties.edits.items.anyOf)).toBe(true) + expect(result.properties.edits.items.type).toBeUndefined() + }) + + test("does not add sibling keys to combiner nodes during sanitize", () => { + const schema = { + type: "object", + properties: { + edits: { + type: "array", + items: { + anyOf: [{ type: "string" }, { type: "number" }], + }, + }, + value: { + oneOf: [{ type: "string" }, { type: "boolean" }], + }, + meta: { + allOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + }, + { + type: "object", + properties: { b: { type: "string" } }, + }, + ], + }, + }, + } as any + const input = JSON.parse(JSON.stringify(schema)) + const result = ProviderTransform.schema(geminiModel, schema) as any + + walk(result, (node, path) => { + const hasCombiner = Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf) + if (!hasCombiner) { + return + } + const before = path.reduce((acc: any, key) => acc?.[key], input) + const added = Object.keys(node).filter((key) => !(key in before)) + expect(added).toEqual([]) + }) + }) +}) + describe("ProviderTransform.schema - gemini non-object properties removal", () => { const geminiModel = { providerID: "google",