fix(opencode): avoid gemini combiner schema sibling injection (#15318)

This commit is contained in:
Shoubhit Dash 2026-03-03 10:11:05 +05:30 committed by GitHub
parent e41b53504f
commit 7e3e85ba59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 130 additions and 5 deletions

View File

@ -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<string, any> =>
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
}

View File

@ -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",