From bc780643d5f207a74eee8ec6b38860748d28f17d Mon Sep 17 00:00:00 2001 From: mingjian Date: Tue, 10 Feb 2026 11:46:56 +0800 Subject: [PATCH] fix(provider): handle anyOf/oneOf/const/$ref in Gemini schema sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini's generateContent API rejects JSON schemas containing anyOf, oneOf, const, $ref, and $defs — all valid JSON Schema but unsupported in Gemini's function calling format. This causes MCP tools from servers like Notion, Memory, and Obsidian mcp-tools to fail with errors like: 'only allowed for STRING type' 'Failed to process error response' Changes: - Add $ref/$defs resolution before sanitization (handles circular refs) - Flatten anyOf/oneOf with const values into enum arrays - Merge multiple object alternatives into single merged object - Pick first type for mixed-type oneOf/anyOf - Single-item unwrap for trivial anyOf/oneOf Tested against real schemas from Notion MCP (22/22 tools fixed), Memory MCP (4 tools), and old Obsidian mcp-tools plugin. --- packages/opencode/src/provider/transform.ts | 75 ++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 01291491d323..383d837ba2a7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -769,11 +769,84 @@ export namespace ProviderTransform { // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { + const defs = (schema as any).$defs || (schema as any).definitions || {} + + // Helper to resolve $ref + const resolveRefs = (obj: any, stack: string[] = []): any => { + if (obj === null || typeof obj !== "object") return obj + + if (obj.$ref) { + const ref = obj.$ref + if (stack.includes(ref)) return { type: "object" } // Break cycle + + if (typeof ref === "string") { + // Handle #/$defs/ and #/definitions/ + const key = ref.replace(/^#\/(?:\$defs|definitions)\//, "") + if (defs[key]) { + return resolveRefs(defs[key], [...stack, ref]) + } + } + return { type: "object" } + } + + if (Array.isArray(obj)) { + return obj.map((item) => resolveRefs(item, stack)) + } + + const result: any = {} + for (const [k, v] of Object.entries(obj)) { + if (k === "$defs" || k === "definitions") continue + result[k] = resolveRefs(v, stack) + } + return result + } + + const resolvedSchema = resolveRefs(schema) + const sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { return obj } + // Handle anyOf/oneOf flattening + if (obj.anyOf || obj.oneOf) { + const options = (obj.anyOf || obj.oneOf).map(sanitizeGemini) + + if (options.length === 0) return { type: "object" } + if (options.length === 1) return options[0] + + // Pattern 2: const values -> enum + const isConst = options.every((o: any) => o.const !== undefined) + if (isConst) { + const types = new Set(options.map((o: any) => o.type).filter(Boolean)) + let type = types.size === 1 ? [...types][0] : "string" + if (types.size === 0) { + const val = options[0].const + type = typeof val === "number" ? "number" : typeof val === "boolean" ? "boolean" : "string" + } + + return { + type, + enum: options.map((o: any) => o.const), + } + } + + // Pattern 4: Multiple objects -> merge + const isAllObjects = options.every((o: any) => o.type === "object" || (!o.type && o.properties)) + if (isAllObjects) { + const merged: any = { type: "object", properties: {} } + for (const opt of options) { + if (opt.properties) { + Object.assign(merged.properties, opt.properties) + } + } + return merged + } + + // Pattern 3: Different types -> pick first + return options[0] + } + if (Array.isArray(obj)) { return obj.map(sanitizeGemini) } @@ -819,7 +892,7 @@ export namespace ProviderTransform { return result } - schema = sanitizeGemini(schema) + schema = sanitizeGemini(resolvedSchema) } return schema as JSONSchema7