mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-22 16:44:36 +00:00
fix(opencode): avoid gemini combiner schema sibling injection (#15318)
This commit is contained in:
@@ -897,6 +897,32 @@ export namespace ProviderTransform {
|
|||||||
|
|
||||||
// Convert integer enums to string enums for Google/Gemini
|
// Convert integer enums to string enums for Google/Gemini
|
||||||
if (model.providerID === "google" || model.api.id.includes("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 => {
|
const sanitizeGemini = (obj: any): any => {
|
||||||
if (obj === null || typeof obj !== "object") {
|
if (obj === null || typeof obj !== "object") {
|
||||||
return obj
|
return obj
|
||||||
@@ -927,19 +953,18 @@ export namespace ProviderTransform {
|
|||||||
result.required = result.required.filter((field: any) => field in result.properties)
|
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) {
|
if (result.items == null) {
|
||||||
result.items = {}
|
result.items = {}
|
||||||
}
|
}
|
||||||
// Ensure items has at least a type if it's an empty object
|
// Ensure items has a type only when it's still schema-empty.
|
||||||
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
|
if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {
|
||||||
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
|
|
||||||
result.items.type = "string"
|
result.items.type = "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove properties/required from non-object types (Gemini rejects these)
|
// 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.properties
|
||||||
delete result.required
|
delete result.required
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
describe("ProviderTransform.schema - gemini non-object properties removal", () => {
|
||||||
const geminiModel = {
|
const geminiModel = {
|
||||||
providerID: "google",
|
providerID: "google",
|
||||||
|
|||||||
Reference in New Issue
Block a user