mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 13:54:01 +00:00
fix(opencode): avoid gemini combiner schema sibling injection (#15318)
This commit is contained in:
parent
e41b53504f
commit
7e3e85ba59
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user