diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index 3f889170a..d68d1c4f2 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -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 = {}; - 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 = 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") { diff --git a/packages/ai/test/google-shared-convert-tools.test.ts b/packages/ai/test/google-shared-convert-tools.test.ts index d9d4d8b36..02ba47d0a 100644 --- a/packages/ai/test/google-shared-convert-tools.test.ts +++ b/packages/ai/test/google-shared-convert-tools.test.ts @@ -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",