fix(ai): preserve literal data in Gemini tool schemas

Restrict const rewriting to schema-bearing keywords and preserve __proto__ names when copying schema maps. Add regression coverage.

Closes #11308
This commit is contained in:
Petr Houška 2026-10-23 21:42:51 +02:00
parent 7314819f0f
commit a7b94a03f0
2 changed files with 121 additions and 5 deletions

View file

@ -372,7 +372,34 @@ function sanitizeForOpenApi(schema: unknown): unknown {
}
/** Schema keywords whose values map names to subschemas, so their keys are not schema keywords. */
const JSON_SCHEMA_NAME_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
const JSON_SCHEMA_NAME_MAPS = new Set([
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
]);
/** Keywords containing a subschema or an array of subschemas, not literal data. */
const JSON_SCHEMA_SUBSCHEMAS = new Set([
"additionalProperties",
"unevaluatedProperties",
"propertyNames",
"items",
"prefixItems",
"additionalItems",
"unevaluatedItems",
"contains",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
"contentSchema",
]);
/**
* Rewrite string and number `const` values to single-value `enum`s.
@ -382,10 +409,14 @@ function rewriteConstAsEnum(schema: unknown, isNameMap = false): unknown {
if (Array.isArray(schema)) return schema.map((item) => rewriteConstAsEnum(item));
if (typeof schema !== "object" || schema === null) return schema;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
result[key] = isNameMap ? rewriteConstAsEnum(value) : rewriteConstAsEnum(value, JSON_SCHEMA_NAME_MAPS.has(key));
}
// fromEntries preserves names such as __proto__ as own properties.
const result: Record<string, unknown> = Object.fromEntries(
Object.entries(schema).map(([key, value]) => {
if (isNameMap || JSON_SCHEMA_SUBSCHEMAS.has(key)) return [key, rewriteConstAsEnum(value)];
if (JSON_SCHEMA_NAME_MAPS.has(key)) return [key, rewriteConstAsEnum(value, true)];
return [key, value];
}),
);
if (!isNameMap && "const" in result && result.enum === undefined) {
const value = result.const;
if (typeof value === "string" || typeof value === "number") {

View file

@ -202,7 +202,92 @@ describe("google-shared convertTools", () => {
});
});
// Regression coverage for #11308.
describe("google-shared convertTools const handling", () => {
it.each(["default", "examples", "enum", "const", "x-custom"])("preserves literal data under %s", (key) => {
const literal = { const: "literal-data", properties: { nested: { const: 1 } } };
const parameters = {
type: "object",
properties: { value: { type: "object", [key]: key === "enum" || key === "examples" ? [literal] : literal } },
};
const original = structuredClone(parameters);
expect(convertTools([makeTool(parameters)])?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema).toEqual(
original,
);
expect(parameters).toEqual(original);
});
it.each(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"])(
"preserves names while rewriting schemas in %s",
(key) => {
const parameters = {
type: "object",
[key]: {
["__proto__"]: { const: "x" },
const: { const: "y" },
properties: { const: "z" },
},
};
const original = structuredClone(parameters);
const expected = {
type: "object",
[key]: {
["__proto__"]: { enum: ["x"] },
const: { enum: ["y"] },
properties: { enum: ["z"] },
},
};
const result = convertTools([makeTool(parameters)])?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema;
expect(result).toEqual(expected);
expect(JSON.stringify(result)).toBe(JSON.stringify(expected));
expect(parameters).toEqual(original);
},
);
it("rewrites only schema-bearing keywords", () => {
const parameters = {
type: "object",
additionalProperties: { const: "x" },
unevaluatedProperties: false,
propertyNames: { const: "name" },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema conditional keyword, not a thenable.
allOf: [{ if: { const: "x" }, then: { const: "y" }, else: { not: { const: "z" } } }],
properties: {
list: {
type: "array",
items: [{ const: 1 }],
prefixItems: [{ const: 2 }],
additionalItems: { const: 3 },
contains: { const: 4 },
unevaluatedItems: { const: 5 },
},
},
dependencies: { list: ["other"] },
};
expect(convertTools([makeTool(parameters)])?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema).toEqual({
type: "object",
additionalProperties: { enum: ["x"] },
unevaluatedProperties: false,
propertyNames: { enum: ["name"] },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema conditional keyword, not a thenable.
allOf: [{ if: { enum: ["x"] }, then: { enum: ["y"] }, else: { not: { enum: ["z"] } } }],
properties: {
list: {
type: "array",
items: [{ enum: [1] }],
prefixItems: [{ enum: [2] }],
additionalItems: { enum: [3] },
contains: { enum: [4] },
unevaluatedItems: { enum: [5] },
},
},
dependencies: { list: ["other"] },
});
});
it("rewrites string and number const values to single-value enums in parametersJsonSchema", () => {
const parameters = {
type: "object",