diff --git a/packages/ai/src/protocols/utils/moonshot-json-schema.ts b/packages/ai/src/protocols/utils/moonshot-json-schema.ts index 3851084f122..096789dd36e 100644 --- a/packages/ai/src/protocols/utils/moonshot-json-schema.ts +++ b/packages/ai/src/protocols/utils/moonshot-json-schema.ts @@ -25,6 +25,7 @@ const ANNOTATIONS = new Set([ ]) const TYPES = new Set(["null", "boolean", "object", "array", "number", "integer", "string"]) const NULLABLE_ENUM_TYPES = new Set(["string", "number", "integer", "boolean"]) +const RESERVED_PROPERTIES = new Set(["$defs", "$ref", "anyOf", "required", "additionalProperties"]) const mapValues = (record: Record, map: (value: unknown, key: string) => unknown) => Object.fromEntries(Object.entries(record).map(([key, value]) => [key, map(value, key)])) @@ -110,6 +111,9 @@ const mergeObjects = (schema: Record): Record !isRecord(branch) || branch.type !== "object" || !isRecord(branch.properties) || + Object.entries(branch.properties).some(([name, value]) => RESERVED_PROPERTIES.has(name) || !isRecord(value)) || + ("required" in branch && + (!Array.isArray(branch.required) || branch.required.some((name) => typeof name !== "string"))) || Object.keys(branch).some((key) => !["type", "properties", "required"].includes(key)), ) ) @@ -123,7 +127,11 @@ const mergeObjects = (schema: Record): Record ...omit(schema, "allOf"), type: "object", properties, - required: [...new Set(branches.flatMap((branch) => (Array.isArray(branch.required) ? branch.required : [])))], + ...(branches.some((branch) => "required" in branch) + ? { + required: [...new Set(branches.flatMap((branch) => (Array.isArray(branch.required) ? branch.required : [])))], + } + : {}), } } @@ -136,7 +144,16 @@ const taggedUnion = (schema: Record, root: JsonSchema) => { if (typeof branch.$ref !== "string") return branch return resolve(branch.$ref, root) }) - if (branches.some((branch) => !branch || branch.type !== "object" || !isRecord(branch.properties))) return schema + if ( + branches.some( + (branch) => + !branch || + branch.type !== "object" || + !isRecord(branch.properties) || + Object.entries(branch.properties).some(([name, value]) => RESERVED_PROPERTIES.has(name) || !isRecord(value)), + ) + ) + return schema const first = branches[0] if (!first || !isRecord(first.properties)) return schema const tag = Object.keys(first.properties).find((name) => { @@ -273,6 +290,9 @@ const typeList = (schema: Record) => { const constraints = omit(schema, "type", "$defs", "$id", "description", "title", "default") if (Object.keys(constraints).length === 0 && !("default" in schema)) return schema if (Object.keys(constraints).length === 1 && "enum" in constraints && !("default" in schema)) return schema + // Walle accepts a null bound on a type list but rejects it on its individual branches. + if (["minLength", "maxLength", "minItems", "maxItems", "minimum", "maximum"].some((key) => constraints[key] === null)) + return schema const branches = schema.type.flatMap((type) => { if (!Array.isArray(constraints.enum)) return [integerBounds({ type, ...constraints })] const values = constraints.enum.filter( diff --git a/packages/ai/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts index aa5d2496dbe..f759372a21c 100644 --- a/packages/ai/src/protocols/utils/tool-schema.ts +++ b/packages/ai/src/protocols/utils/tool-schema.ts @@ -1,4 +1,5 @@ import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js" +import { isRecord } from "../../utils/record.js" import { GeminiJsonSchema } from "./gemini-json-schema.js" import { MoonshotJsonSchema } from "./moonshot-json-schema.js" diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index e1d7bf986b6..b104c62b680 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -273,6 +273,32 @@ describe("tool schema projections", () => { }) }) + test("moonshot does not expose ignored invalid branches to validation", () => { + const schema = { + type: "object", + properties: { + nullable: { type: ["string", "null"], minLength: null }, + intersection: { + allOf: [ + { type: "object", properties: { disabled: false } }, + { type: "object", properties: { enabled: { type: "string" } } }, + ], + }, + tagged: { + oneOf: [ + { + type: "object", + properties: { kind: { type: "string", enum: ["a"] }, disabled: false }, + required: ["kind"], + }, + { type: "object", properties: { kind: { type: "string", enum: ["b"] } }, required: ["kind"] }, + ], + }, + }, + } + expect(ToolSchemaProjection.moonshot(schema)).toEqual(schema) + }) + test("moonshot handles boolean branches and tuple bounds", () => { expect( ToolSchemaProjection.moonshot({