From 67fe7b2fb77e5d87ae4b2f0b650073ca974845fe Mon Sep 17 00:00:00 2001 From: killer30001000 Date: Fri, 11 Sep 2026 22:10:12 +0200 Subject: [PATCH] fix(translator): do not treat a properties map as a schema node Vertex AI rejects tool schemas when Phase 7 of cleanJSONSchemaForAntigravity() mistakes a properties map for a schema node and injects a synthetic type entry. A tool that legitimately declares a parameter named `properties` triggers this path, producing an invalid ninth parameters.properties value (`"object"`). Make injectObjectType() schema-map aware: descend into values of a `properties` map without processing the map itself as a schema node. Add a focused regression reproducing the Vertex failure and guard other keyword-named parameters. --- open-sse/translator/helpers/geminiHelper.ts | 16 ++- ...i-schema-properties-named-property.test.ts | 114 ++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 tests/unit/gemini-schema-properties-named-property.test.ts diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 95fea6dcea0c..7dcc0b87c020 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -837,9 +837,19 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { record.type = "object"; } - // Recurse into remaining values. - for (const value of Object.values(record)) { - if (value && typeof value === "object") { + // Recurse into remaining values. `properties` is a map keyed by arbitrary, + // user-defined property NAMES — a tool may legitimately declare a property + // called `properties` (or `required`, etc). The map itself is never a + // schema node, so it must never receive `type: "object"`; only descend + // into each entry's subschema. Mirrors the same guard already applied in + // removeUnsupportedKeywords() above. + for (const [key, value] of Object.entries(record)) { + if (!value || typeof value !== "object") continue; + if (key === "properties" && !Array.isArray(value)) { + for (const subSchema of Object.values(value as JsonRecord)) { + injectObjectType(subSchema); + } + } else { injectObjectType(value); } } diff --git a/tests/unit/gemini-schema-properties-named-property.test.ts b/tests/unit/gemini-schema-properties-named-property.test.ts new file mode 100644 index 000000000000..5ab13cd45cad --- /dev/null +++ b/tests/unit/gemini-schema-properties-named-property.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { cleanJSONSchemaForAntigravity } = await import( + "../../open-sse/translator/helpers/geminiHelper.ts" +); + +// Reproduces the Vertex 400: +// Invalid value at 'tools[0].function_declarations[43].parameters.properties[8].value' +// (type.googleapis.com/google.cloud.aiplatform.v1.Schema), "object" +// The github-custom_properties_write tool declares a legitimate parameter named +// `properties`. The type:"object" injection visitor mistook the root property +// MAP for a schema node (because `map.properties !== undefined`) and wrote +// `type: "object"` into the map, turning it into a 9th, non-Schema entry. +const githubCustomPropertiesWriteSchema = () => ({ + type: "object", + properties: { + security_risk: { type: "string" }, + summary: { type: "string" }, + enterprise: { type: "string" }, + level: { type: "string" }, + org: { type: "string" }, + owner: { type: "string" }, + properties: { + type: "array", + items: { + type: "object", + properties: {}, + additionalProperties: true, + }, + }, + repo: { type: "string" }, + }, + required: ["level", "properties"], +}); + +test("a tool parameter named `properties` does not inject type into the property map", () => { + const result = cleanJSONSchemaForAntigravity( + githubCustomPropertiesWriteSchema() + ) as Record; + + const props = result.properties as Record; + + assert.deepEqual( + Object.keys(props).sort(), + [ + "enterprise", + "level", + "org", + "owner", + "properties", + "repo", + "security_risk", + "summary", + ], + "the root property map must keep exactly the declared parameter names" + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(props, "type"), + "no synthetic `type` entry may be added to the property map" + ); + assert.equal(result.type, "object", "the root schema itself must stay type:object"); +}); + +test("the legitimate `properties` parameter keeps its own array schema", () => { + const result = cleanJSONSchemaForAntigravity( + githubCustomPropertiesWriteSchema() + ) as Record; + + const props = result.properties as Record; + const propertiesParam = props.properties as Record; + + assert.ok(propertiesParam, "the `properties` parameter must survive cleaning"); + assert.equal(propertiesParam.type, "array", "its type:array must be preserved"); + + const items = propertiesParam.items as Record; + assert.ok(items, "its items schema must be preserved"); + assert.equal(items.type, "object", "its items schema must stay a valid object schema"); + assert.ok( + !("additionalProperties" in items), + "Gemini/Vertex sanitisation must still remove additionalProperties" + ); +}); + +test("other schema keyword names used as parameter names are not treated as metadata", () => { + const input = { + type: "object", + properties: { + required: { type: "string" }, + items: { type: "string" }, + type: { type: "string" }, + description: { type: "string" }, + }, + required: ["required"], + }; + + const result = cleanJSONSchemaForAntigravity(input) as Record; + const props = result.properties as Record; + + assert.deepEqual( + Object.keys(props).sort(), + ["description", "items", "required", "type"], + "keyword-named parameters must survive untouched and gain no siblings" + ); + for (const [name, subSchema] of Object.entries(props)) { + assert.equal( + (subSchema as Record).type, + "string", + `parameter \`${name}\` must keep its declared string type` + ); + } + assert.equal(result.type, "object"); + assert.deepEqual(result.required, ["required"]); +});