summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-03-03 10:11:05 +0530
committerGitHub <[email protected]>2026-03-03 10:11:05 +0530
commit7e3e85ba596b8fd837bc61410b4d224908486918 (patch)
tree5652038b177b0db8f9c89ed155c2d83388bb63fe /packages
parente41b53504f193de3e6799836e53c7400952a4d2c (diff)
downloadopencode-7e3e85ba596b8fd837bc61410b4d224908486918.tar.gz
opencode-7e3e85ba596b8fd837bc61410b4d224908486918.zip
fix(opencode): avoid gemini combiner schema sibling injection (#15318)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/provider/transform.ts35
-rw-r--r--packages/opencode/test/provider/transform.test.ts100
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<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
}
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",