Skip to content

Commit 7e3e85b

Browse files
authored
fix(opencode): avoid gemini combiner schema sibling injection (anomalyco#15318)
1 parent e41b535 commit 7e3e85b

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,32 @@ export namespace ProviderTransform {
897897

898898
// Convert integer enums to string enums for Google/Gemini
899899
if (model.providerID === "google" || model.api.id.includes("gemini")) {
900+
const isPlainObject = (node: unknown): node is Record<string, any> =>
901+
typeof node === "object" && node !== null && !Array.isArray(node)
902+
const hasCombiner = (node: unknown) =>
903+
isPlainObject(node) &&
904+
(Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf))
905+
const hasSchemaIntent = (node: unknown) => {
906+
if (!isPlainObject(node)) return false
907+
if (hasCombiner(node)) return true
908+
return [
909+
"type",
910+
"properties",
911+
"items",
912+
"prefixItems",
913+
"enum",
914+
"const",
915+
"$ref",
916+
"additionalProperties",
917+
"patternProperties",
918+
"required",
919+
"not",
920+
"if",
921+
"then",
922+
"else",
923+
].some((key) => key in node)
924+
}
925+
900926
const sanitizeGemini = (obj: any): any => {
901927
if (obj === null || typeof obj !== "object") {
902928
return obj
@@ -927,19 +953,18 @@ export namespace ProviderTransform {
927953
result.required = result.required.filter((field: any) => field in result.properties)
928954
}
929955

930-
if (result.type === "array") {
956+
if (result.type === "array" && !hasCombiner(result)) {
931957
if (result.items == null) {
932958
result.items = {}
933959
}
934-
// Ensure items has at least a type if it's an empty object
935-
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
936-
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
960+
// Ensure items has a type only when it's still schema-empty.
961+
if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {
937962
result.items.type = "string"
938963
}
939964
}
940965

941966
// Remove properties/required from non-object types (Gemini rejects these)
942-
if (result.type && result.type !== "object") {
967+
if (result.type && result.type !== "object" && !hasCombiner(result)) {
943968
delete result.properties
944969
delete result.required
945970
}

packages/opencode/test/provider/transform.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,106 @@ describe("ProviderTransform.schema - gemini nested array items", () => {
510510
})
511511
})
512512

513+
describe("ProviderTransform.schema - gemini combiner nodes", () => {
514+
const geminiModel = {
515+
providerID: "google",
516+
api: {
517+
id: "gemini-3-pro",
518+
},
519+
} as any
520+
521+
const walk = (node: any, cb: (node: any, path: (string | number)[]) => void, path: (string | number)[] = []) => {
522+
if (node === null || typeof node !== "object") {
523+
return
524+
}
525+
if (Array.isArray(node)) {
526+
node.forEach((item, i) => walk(item, cb, [...path, i]))
527+
return
528+
}
529+
cb(node, path)
530+
Object.entries(node).forEach(([key, value]) => walk(value, cb, [...path, key]))
531+
}
532+
533+
test("keeps edits.items.anyOf without adding type", () => {
534+
const schema = {
535+
type: "object",
536+
properties: {
537+
edits: {
538+
type: "array",
539+
items: {
540+
anyOf: [
541+
{
542+
type: "object",
543+
properties: {
544+
old_string: { type: "string" },
545+
new_string: { type: "string" },
546+
},
547+
required: ["old_string", "new_string"],
548+
},
549+
{
550+
type: "object",
551+
properties: {
552+
old_string: { type: "string" },
553+
new_string: { type: "string" },
554+
replace_all: { type: "boolean" },
555+
},
556+
required: ["old_string", "new_string"],
557+
},
558+
],
559+
},
560+
},
561+
},
562+
required: ["edits"],
563+
} as any
564+
565+
const result = ProviderTransform.schema(geminiModel, schema) as any
566+
567+
expect(Array.isArray(result.properties.edits.items.anyOf)).toBe(true)
568+
expect(result.properties.edits.items.type).toBeUndefined()
569+
})
570+
571+
test("does not add sibling keys to combiner nodes during sanitize", () => {
572+
const schema = {
573+
type: "object",
574+
properties: {
575+
edits: {
576+
type: "array",
577+
items: {
578+
anyOf: [{ type: "string" }, { type: "number" }],
579+
},
580+
},
581+
value: {
582+
oneOf: [{ type: "string" }, { type: "boolean" }],
583+
},
584+
meta: {
585+
allOf: [
586+
{
587+
type: "object",
588+
properties: { a: { type: "string" } },
589+
},
590+
{
591+
type: "object",
592+
properties: { b: { type: "string" } },
593+
},
594+
],
595+
},
596+
},
597+
} as any
598+
const input = JSON.parse(JSON.stringify(schema))
599+
const result = ProviderTransform.schema(geminiModel, schema) as any
600+
601+
walk(result, (node, path) => {
602+
const hasCombiner = Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)
603+
if (!hasCombiner) {
604+
return
605+
}
606+
const before = path.reduce((acc: any, key) => acc?.[key], input)
607+
const added = Object.keys(node).filter((key) => !(key in before))
608+
expect(added).toEqual([])
609+
})
610+
})
611+
})
612+
513613
describe("ProviderTransform.schema - gemini non-object properties removal", () => {
514614
const geminiModel = {
515615
providerID: "google",

0 commit comments

Comments
 (0)