From 9f284fb1c75227677a83f22e3e12a1f4070edfca Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 24 Sep 2026 11:11:45 -0500 Subject: [PATCH] fix(ai): preserve Moonshot schema semantics across unions and refs --- .../protocols/utils/moonshot-json-schema.ts | 360 ++++++++++++++---- packages/ai/test/provider/moonshot.test.ts | 4 +- .../ai/test/tool-schema-projection.test.ts | 150 +++++++- 3 files changed, 439 insertions(+), 75 deletions(-) diff --git a/packages/ai/src/protocols/utils/moonshot-json-schema.ts b/packages/ai/src/protocols/utils/moonshot-json-schema.ts index e5966e9d6b9..3851084f122 100644 --- a/packages/ai/src/protocols/utils/moonshot-json-schema.ts +++ b/packages/ai/src/protocols/utils/moonshot-json-schema.ts @@ -1,9 +1,8 @@ import type { JsonSchema } from "../../schema/index.js" import { isRecord } from "../../utils/record.js" -// Moonshot validates tool schemas against its own JSON Schema subset, then constrains tool arguments -// to a copy of the schema without the keywords that subset lacks. Rewrite the shapes published tool -// schemas commonly contain into supported equivalents and send everything else unchanged. +// Moonshot accepts more JSON Schema than it enforces. Keep unsupported constructs when there is no +// equivalent in its subset, rather than silently changing their meaning on other Kimi hosts. const SCHEMA_MAPS = new Set([ "properties", "patternProperties", @@ -24,6 +23,7 @@ const ANNOTATIONS = new Set([ "writeOnly", "$comment", ]) +const TYPES = new Set(["null", "boolean", "object", "array", "number", "integer", "string"]) const NULLABLE_ENUM_TYPES = new Set(["string", "number", "integer", "boolean"]) const mapValues = (record: Record, map: (value: unknown, key: string) => unknown) => @@ -34,92 +34,320 @@ const omit = (record: Record, ...keys: string[]) => const jsonType = (value: unknown) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value) -// Moonshot rejects boolean schemas in schema maps and array items. `true` accepts anything, like `{}`. -const subschema = (value: unknown) => (value === true ? {} : normalizeNode(value)) - -const normalizeNode = (schema: unknown): unknown => { - if (Array.isArray(schema)) return schema.map(normalizeNode) - if (!isRecord(schema)) return schema - const normalized = mapValues(schema, (value, key) => { - if (VALUES.has(key)) return value - if (SCHEMA_MAPS.has(key) && isRecord(value)) return mapValues(value, subschema) - if (key === "items" || key === "prefixItems") return Array.isArray(value) ? value.map(subschema) : subschema(value) - return normalizeNode(value) +// Walle permits references at the root, in properties, definitions, items, additionalProperties, +// and anyOf. An allOf wrapper can only be unwrapped where its resulting $ref is legal. +const node = (value: unknown, root: JsonSchema, allowRef: boolean): unknown => { + if (!isRecord(value)) return value + const normalized = mapValues(value, (child, key) => { + if (VALUES.has(key)) return child + if (SCHEMA_MAPS.has(key) && isRecord(child)) + return mapValues(child, (entry) => { + // Walle rejects a referenced definition containing only annotations as non-terminating. + // A union of the six JSON types means exactly the same thing as no type restriction. + const definition = + key === "$defs" && + isRecord(entry) && + Object.keys(entry).length > 0 && + Object.keys(entry).every((name) => ANNOTATIONS.has(name)) + ? { ...entry, type: ["null", "boolean", "object", "array", "number", "string"] } + : entry + return schemaNode(definition, root, key === "properties" || key === "$defs") + }) + if (key === "items" || key === "prefixItems") + return Array.isArray(child) + ? child.map((entry) => schemaNode(entry, root, key === "items")) + : schemaNode(child, root, true) + if (key === "anyOf" || key === "oneOf") + return Array.isArray(child) + ? child.map((entry) => schemaNode(entry, root, key === "anyOf")).filter((entry) => entry !== false) + : child + if (key === "allOf" || key === "not") + return Array.isArray(child) ? child.map((entry) => node(entry, root, false)) : node(child, root, false) + if (key === "additionalProperties") return typeof child === "boolean" ? child : schemaNode(child, root, true) + return child }) - return [unwrapAllOf, constEnum, tupleItems, enumType, refAnyOf].reduce((node, rewrite) => rewrite(node), normalized) + + const unwrapped = mergeObjects(unwrapRef(normalized, allowRef)) + const constant = "const" in unwrapped ? unwrapped.const : undefined + const literal = + "const" in unwrapped && !("enum" in unwrapped) && !hasUnionTarget(unwrapped, root) + ? { ...omit(unwrapped, "const"), enum: [unwrapped.const] } + : "const" in unwrapped && + Array.isArray(unwrapped.enum) && + (constant === null || ["string", "number", "boolean"].includes(typeof constant)) && + unwrapped.enum.some((item) => item === constant) + ? { ...omit(unwrapped, "const"), enum: [constant] } + : unwrapped + const union = taggedUnion(literal, root) + const tuple = tupleItems(union) + const typed = enumType(tuple, root) + const split = typeList(typed) + return spreadRef(integerBounds(split), root) } -// Moonshot drops `allOf`. A lone `allOf: [{ $ref }]` beside annotations, which older generators emit -// for a described reference, means the same as the `$ref` itself. -const unwrapAllOf = (schema: Record) => { - if (!Array.isArray(schema.allOf) || schema.allOf.length !== 1) return schema +const schemaNode = (value: unknown, root: JsonSchema, allowRef: boolean) => + value === true ? {} : node(value, root, allowRef) + +// This is the Pydantic/Zod draft-7 shape for a described reference. Other allOf intersections +// must not be merged: properties and additionalProperties interact across branches. +const unwrapRef = (schema: Record, allowRef: boolean) => { + if (!allowRef || !Array.isArray(schema.allOf) || schema.allOf.length !== 1) return schema const entry = schema.allOf[0] if (!isRecord(entry) || typeof entry.$ref !== "string" || Object.keys(entry).length !== 1) return schema if (Object.keys(schema).some((key) => key !== "allOf" && !ANNOTATIONS.has(key))) return schema return { ...omit(schema, "allOf"), $ref: entry.$ref } } -// Moonshot drops `const`, so a literal becomes a one-value `enum`. -const constEnum = (schema: Record) => - "const" in schema && !("enum" in schema) ? { ...omit(schema, "const"), enum: [schema.const] } : schema +// Intersections of independently declared object properties can be flattened as long as no branch +// closes the object or redeclares a property. Other allOf compositions remain unchanged. +const mergeObjects = (schema: Record): Record => { + if (!Array.isArray(schema.allOf) || schema.allOf.length < 2) return schema + if (Object.keys(schema).some((key) => key !== "allOf" && !ANNOTATIONS.has(key))) return schema + const branches = schema.allOf + if ( + branches.some( + (branch) => + !isRecord(branch) || + branch.type !== "object" || + !isRecord(branch.properties) || + Object.keys(branch).some((key) => !["type", "properties", "required"].includes(key)), + ) + ) + return schema + const properties = Object.fromEntries(branches.flatMap((branch) => Object.entries(branch.properties))) + if ( + Object.keys(properties).length !== branches.reduce((sum, branch) => sum + Object.keys(branch.properties).length, 0) + ) + return schema + return { + ...omit(schema, "allOf"), + type: "object", + properties, + required: [...new Set(branches.flatMap((branch) => (Array.isArray(branch.required) ? branch.required : [])))], + } +} -// Moonshot's `items` is one schema for every element, so tuple positions and any rest schema become -// an `anyOf` of their schemas. +// A discriminator makes the alternatives exclusive, so oneOf and anyOf accept exactly the same +// instances. Preserve oneOf when exclusivity cannot be proven. +const taggedUnion = (schema: Record, root: JsonSchema) => { + if (!Array.isArray(schema.oneOf) || schema.oneOf.length < 2 || "anyOf" in schema) return schema + const branches = schema.oneOf.map((branch) => { + if (!isRecord(branch)) return undefined + 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 + const first = branches[0] + if (!first || !isRecord(first.properties)) return schema + const tag = Object.keys(first.properties).find((name) => { + const values = branches.map((branch) => { + if (!branch || !isRecord(branch.properties) || !Array.isArray(branch.required) || !branch.required.includes(name)) + return [] + const property = branch.properties[name] + if (!isRecord(property)) return [] + return Array.isArray(property.enum) ? property.enum : "const" in property ? [property.const] : [] + }) + return ( + values.every( + (items) => + items.length > 0 && + items.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item)), + ) && + new Set(values.flat().map((item) => JSON.stringify(item))).size === + values.reduce((sum, items) => sum + items.length, 0) + ) + }) + if (!tag) return schema + return { ...omit(schema, "oneOf"), anyOf: schema.oneOf.map((branch) => schemaNode(branch, root, true)) } +} + +// Distributing a reference across anyOf avoids Walle's type/anyOf conflict only when the +// referenced schema declares a type rather than another union. +const spreadRef = (schema: Record, root: JsonSchema) => { + if (typeof schema.$ref !== "string" || !Array.isArray(schema.anyOf)) return schema + const target = resolve(schema.$ref, root) + if (!target || !("type" in target) || Array.isArray(target.anyOf) || Array.isArray(target.oneOf)) return schema + if (schema.anyOf.some((branch) => !isRecord(branch) || "$ref" in branch || "anyOf" in branch)) return schema + return { ...omit(schema, "$ref"), anyOf: schema.anyOf.map((branch) => ({ $ref: schema.$ref, ...branch })) } +} + +// Moonshot has one `items` schema for all array entries. For open tuples, later entries may be +// anything; for closed tuples, maxItems retains their length constraint. const tupleItems = (schema: Record) => { - if (Array.isArray(schema.prefixItems)) - return { - ...omit(schema, "prefixItems"), - items: anyOf([...schema.prefixItems, ...(isRecord(schema.items) ? [schema.items] : [])]), - } - if (Array.isArray(schema.items)) - return { - ...omit(schema, "additionalItems"), - items: anyOf([...schema.items, ...(isRecord(schema.additionalItems) ? [schema.additionalItems] : [])]), - } - return schema + const prefix = Array.isArray(schema.prefixItems) + ? schema.prefixItems + : Array.isArray(schema.items) + ? schema.items + : undefined + if (!prefix) { + if (schema.items === false) return { ...omit(schema, "items"), maxItems: 0 } + return schema + } + const modern = Array.isArray(schema.prefixItems) + const rest = modern ? schema.items : schema.additionalItems + const blocked = prefix.indexOf(false) + const closed = rest === false || blocked >= 0 + const limit = blocked >= 0 ? blocked : prefix.length + const maximum = + closed || blocked >= 0 ? Math.min(typeof schema.maxItems === "number" ? schema.maxItems : limit, limit) : undefined + const entries = prefix.slice(0, limit) + if (modern && "unevaluatedItems" in schema && rest === undefined) return schema + const items = + !closed && (rest === undefined || rest === true) + ? {} + : entries.length === 0 + ? {} + : entries.length === 1 && !isRecord(rest) + ? entries[0] + : { anyOf: [...entries, ...(isRecord(rest) ? [rest] : [])] } + return { + ...omit(schema, "items", "additionalItems"), + ...(maximum === undefined ? {} : { maxItems: maximum }), + ...(maximum === 0 ? {} : { items }), + } } -const anyOf = (schemas: ReadonlyArray) => { - if (schemas.length === 0) return {} - if (schemas.length === 1) return schemas[0] - return { anyOf: schemas } -} - -// Moonshot rejects an `enum` without a single `type`, and a type list beside `enum` may only pair -// "null" with one primitive type. Derive the type from the values, splitting the rest into `anyOf`. -const enumType = (schema: Record) => { - if (!Array.isArray(schema.enum) || typeof schema.type === "string" || nullableEnumType(schema.type)) return schema - const values = schema.enum - const types = [...new Set(values.map(jsonType))] - const kinds = types.filter((item) => item !== "null") - if (kinds.length === 0) return values.length === 0 ? schema : { ...schema, type: "null" } - if (types.length === 1) return { ...schema, type: kinds[0] } - if (kinds.length === 1 && NULLABLE_ENUM_TYPES.has(kinds[0])) return { ...schema, type: [kinds[0], "null"] } - if ("anyOf" in schema) return schema +// An untyped enum is rejected by Moonshot. A multi-type enum is only allowed for one primitive +// plus null; split other combinations without admitting values excluded by an explicit type list. +const enumType = (schema: Record, root: JsonSchema) => { + if (!Array.isArray(schema.enum) || schema.enum.length === 0 || typeof schema.type === "string") return schema + const declared = Array.isArray(schema.type) ? schema.type : undefined + if (declared?.some((type) => !TYPES.has(type))) return schema + if (declared?.length === 1) return { ...schema, type: declared[0] } + if (declared?.length === 2 && declared.includes("null") && declared.some((type) => NULLABLE_ENUM_TYPES.has(type))) + return schema + if (hasUnionTarget(schema, root)) return schema + const values = declared + ? schema.enum.filter((value) => + declared.some( + (type) => + type === jsonType(value) || (type === "integer" && typeof value === "number" && Number.isInteger(value)), + ), + ) + : schema.enum + if (values.length === 0) return schema + const types = [ + ...new Set( + values.map((value) => { + if (declared?.includes("integer") && typeof value === "number" && Number.isInteger(value)) return "integer" + return jsonType(value) + }), + ), + ] + if (types.length === 1) return { ...schema, enum: values, type: types[0] } + if (types.length === 2 && types.includes("null") && types.some((type) => NULLABLE_ENUM_TYPES.has(type))) + return { ...schema, enum: values, type: [types.find((type) => type !== "null"), "null"] } + if (Array.isArray(schema.anyOf)) { + if ( + schema.anyOf.some( + (branch) => !isRecord(branch) || typeof branch.type !== "string" || "$ref" in branch || "anyOf" in branch, + ) + ) + return schema + const branches = schema.anyOf.flatMap((branch) => { + const matches = values.filter( + (value) => + (branch.type === jsonType(value) || + (branch.type === "integer" && typeof value === "number" && Number.isInteger(value))) && + (!Array.isArray(branch.enum) || branch.enum.includes(value)), + ) + return matches.length > 0 ? [{ ...branch, enum: matches }] : [] + }) + return branches.length === 0 ? schema : { ...omit(schema, "type", "enum"), anyOf: branches } + } return { ...omit(schema, "type", "enum"), - anyOf: types.map((item) => - item === "null" ? { type: item } : { type: item, enum: values.filter((value) => jsonType(value) === item) }, + anyOf: types.map((type) => + type === "null" + ? { type } + : { type, enum: values.filter((value) => jsonType(value) === (type === "integer" ? "number" : type)) }, ), } } -const nullableEnumType = (type: unknown) => - Array.isArray(type) && - (type.length === 1 || - (type.length === 2 && type.includes("null") && type.some((item) => NULLABLE_ENUM_TYPES.has(item)))) +// Walle discards constraints beside type arrays, including optional-object properties and string +// lengths. Move them into each type branch; Walle drops inapplicable keywords within each branch. +const typeList = (schema: Record) => { + if (!Array.isArray(schema.type) || schema.type.length < 2 || "anyOf" in schema || "$ref" in schema) return schema + if (schema.type.some((type) => !TYPES.has(type))) return schema + 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 + const branches = schema.type.flatMap((type) => { + if (!Array.isArray(constraints.enum)) return [integerBounds({ type, ...constraints })] + const values = constraints.enum.filter( + (value) => + type === jsonType(value) || (type === "integer" && typeof value === "number" && Number.isInteger(value)), + ) + return values.length > 0 ? [integerBounds({ type, ...constraints, enum: values })] : [] + }) + if (branches.length === 0) return schema + return { + ...omit(schema, "type", ...Object.keys(constraints)), + anyOf: branches, + } +} -// After expanding a `$ref`, Moonshot rejects the target's `type` beside `anyOf`, so the reference -// moves into each branch. -const refAnyOf = (schema: Record) => { - if (typeof schema.$ref !== "string" || !Array.isArray(schema.anyOf)) return schema - const branches = schema.anyOf.filter(isRecord) - if (branches.length !== schema.anyOf.length || branches.some((branch) => "$ref" in branch)) return schema - return { ...omit(schema, "$ref"), anyOf: branches.map((branch) => ({ $ref: schema.$ref, ...branch })) } +// The numeric exclusive bounds are ignored by Moonshot. For integers, their inclusive equivalent +// is exact, including non-integer bounds. +const integerBounds = (schema: Record) => { + if (schema.type !== "integer") return schema + const lower = + typeof schema.exclusiveMinimum === "number" + ? Math.floor(schema.exclusiveMinimum) + 1 + : schema.exclusiveMinimum === true && typeof schema.minimum === "number" + ? Math.floor(schema.minimum) + 1 + : undefined + const upper = + typeof schema.exclusiveMaximum === "number" + ? Math.ceil(schema.exclusiveMaximum) - 1 + : schema.exclusiveMaximum === true && typeof schema.maximum === "number" + ? Math.ceil(schema.maximum) - 1 + : undefined + if (lower === undefined && upper === undefined) return schema + return { + ...omit(schema, "exclusiveMinimum", "exclusiveMaximum"), + ...(lower === undefined + ? {} + : { minimum: typeof schema.minimum === "number" ? Math.max(schema.minimum, lower) : lower }), + ...(upper === undefined + ? {} + : { maximum: typeof schema.maximum === "number" ? Math.min(schema.maximum, upper) : upper }), + } +} + +const resolve = (ref: string, root: JsonSchema): Record | undefined => { + const seen = new Set() + const follow = (pointer: string): Record | undefined => { + if (seen.has(pointer) || !pointer.startsWith("#")) return undefined + seen.add(pointer) + const target = + pointer === "#" + ? root + : pointer.startsWith("#/") + ? pointer + .slice(2) + .split("/") + .reduce( + (current, key) => + isRecord(current) ? current[key.replaceAll("~1", "/").replaceAll("~0", "~")] : undefined, + root, + ) + : undefined + if (!isRecord(target)) return undefined + return typeof target.$ref === "string" ? follow(target.$ref) : target + } + return follow(ref) +} + +const hasUnionTarget = (schema: Record, root: JsonSchema) => { + if (typeof schema.$ref !== "string") return false + const target = resolve(schema.$ref, root) + return !!target && (Array.isArray(target.anyOf) || Array.isArray(target.oneOf)) } export const normalize = (schema: JsonSchema): JsonSchema => { - const normalized = normalizeNode(schema) + const normalized = node(schema, schema, true) return isRecord(normalized) ? normalized : {} } diff --git a/packages/ai/test/provider/moonshot.test.ts b/packages/ai/test/provider/moonshot.test.ts index 9bcf0aa5d7f..d6b994d1c75 100644 --- a/packages/ai/test/provider/moonshot.test.ts +++ b/packages/ai/test/provider/moonshot.test.ts @@ -185,7 +185,9 @@ it.effect("Moonshot retains unsigned and signed Messages reasoning and projects expect.objectContaining({ input_schema: { type: "object", - properties: { pair: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } } }, + properties: { + pair: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }], items: {} }, + }, }, }), ]), diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index de2f24af55b..e1d7bf986b6 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -27,12 +27,21 @@ describe("tool schema projections", () => { type: "object", properties: { linked: { $ref: "#/$defs/Linked", description: "keep me" }, - tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + tuple: { type: "array", items: {} }, tupleRest: { type: "array", items: { anyOf: [{ type: "string" }, { type: "integer" }] } }, - prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } }, - closedTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } }, - closedDraft07: { type: "array", items: { type: "boolean" } }, - prefixRest: { type: "array", items: { anyOf: [{}, { type: "number" }, { type: "string" }] } }, + prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }], items: {} }, + closedTuple: { + type: "array", + prefixItems: [{ type: "boolean" }, { type: "string" }], + maxItems: 2, + items: { anyOf: [{ type: "boolean" }, { type: "string" }] }, + }, + closedDraft07: { type: "array", maxItems: 1, items: { type: "boolean" } }, + prefixRest: { + type: "array", + prefixItems: [{}, { type: "number" }], + items: { anyOf: [{}, { type: "number" }, { type: "string" }] }, + }, }, }) }) @@ -126,6 +135,13 @@ describe("tool schema projections", () => { mixed: { enum: ["a", 1] }, mixedNullable: { description: "Mixed", enum: ["a", 1, null] }, typeList: { type: ["string", "integer"], enum: ["a", 1] }, + inUnion: { + anyOf: [ + { type: "string", minLength: 2 }, + { type: "number", minimum: 0 }, + ], + enum: ["ok", 1], + }, nullableList: { type: ["integer", "null"], enum: [1, null] }, narrowedList: { type: ["string", "null"], enum: ["a", 1] }, onlyNull: { enum: [null] }, @@ -156,7 +172,13 @@ describe("tool schema projections", () => { typeList: { anyOf: [ { type: "string", enum: ["a"] }, - { type: "number", enum: [1] }, + { type: "integer", enum: [1] }, + ], + }, + inUnion: { + anyOf: [ + { type: "string", minLength: 2, enum: ["ok"] }, + { type: "number", minimum: 0, enum: [1] }, ], }, nullableList: { type: ["integer", "null"], enum: [1, null] }, @@ -217,6 +239,118 @@ describe("tool schema projections", () => { }) }), ) + test("moonshot keeps accepted $ref and allOf shapes accepted", () => { + const $defs = { + Union: { anyOf: [{ type: "string" }, { type: "null" }] }, + Alias: { $ref: "#/$defs/Union" }, + Name: { type: "string" }, + } + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + union: { $ref: "#/$defs/Alias", anyOf: [{ type: "string" }, { type: "null" }] }, + literal: { $ref: "#/$defs/Union", const: "a" }, + bounded: { $ref: "#/$defs/Name", anyOf: [{ minLength: 1 }, { maxLength: 3 }] }, + }, + patternProperties: { "^x": { allOf: [{ $ref: "#/$defs/Name" }], description: "ignored by Moonshot" } }, + $defs, + }), + ).toEqual({ + type: "object", + properties: { + union: { $ref: "#/$defs/Alias", anyOf: [{ type: "string" }, { type: "null" }] }, + literal: { $ref: "#/$defs/Union", const: "a" }, + bounded: { + anyOf: [ + { $ref: "#/$defs/Name", minLength: 1 }, + { $ref: "#/$defs/Name", maxLength: 3 }, + ], + }, + }, + patternProperties: { "^x": { allOf: [{ $ref: "#/$defs/Name" }], description: "ignored by Moonshot" } }, + $defs, + }) + }) + + test("moonshot handles boolean branches and tuple bounds", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + choice: { anyOf: [{ type: "string" }, true, false] }, + blocked: { type: "array", prefixItems: [{ type: "string" }, false] }, + extra: { type: "array", items: [{ type: "string" }], additionalItems: true }, + literal: { type: "string", const: "a", enum: ["a", "b"] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + choice: { anyOf: [{ type: "string" }, {}] }, + blocked: { type: "array", prefixItems: [{ type: "string" }, false], maxItems: 1, items: { type: "string" } }, + extra: { type: "array", items: {} }, + literal: { type: "string", enum: ["a"] }, + }, + }) + }) + + test("moonshot supports tagged unions, nullable constraints, and integer bounds", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + pet: { $ref: "#/$defs/Pet" }, + optional: { type: ["string", "null"], minLength: 2, default: null }, + count: { type: "integer", exclusiveMinimum: 0, exclusiveMaximum: 4.5 }, + merged: { + allOf: [ + { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + untouched: { + allOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + }, + $defs: { + Pet: { oneOf: [{ $ref: "#/$defs/Cat" }, { $ref: "#/$defs/Dog" }], discriminator: { propertyName: "kind" } }, + Cat: { type: "object", required: ["kind"], properties: { kind: { type: "string", const: "cat" } } }, + Dog: { type: "object", required: ["kind"], properties: { kind: { type: "string", const: "dog" } } }, + Any: { title: "Arbitrary JSON" }, + }, + }), + ).toEqual({ + type: "object", + properties: { + pet: { $ref: "#/$defs/Pet" }, + optional: { + default: null, + anyOf: [ + { type: "string", minLength: 2 }, + { type: "null", minLength: 2 }, + ], + }, + count: { type: "integer", minimum: 1, maximum: 4 }, + merged: { type: "object", properties: { a: { type: "string" }, b: { type: "number" } }, required: ["a"] }, + untouched: { + allOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + }, + $defs: { + Pet: { discriminator: { propertyName: "kind" }, anyOf: [{ $ref: "#/$defs/Cat" }, { $ref: "#/$defs/Dog" }] }, + Cat: { type: "object", required: ["kind"], properties: { kind: { type: "string", enum: ["cat"] } } }, + Dog: { type: "object", required: ["kind"], properties: { kind: { type: "string", enum: ["dog"] } } }, + Any: { title: "Arbitrary JSON", type: ["null", "boolean", "object", "array", "number", "string"] }, + }, + }) + }) it.effect("selects tool schema handling from the model name unless compatibility is explicit", () => Effect.gen(function* () { @@ -258,7 +392,7 @@ describe("tool schema projections", () => { }), ) - it.effect("applies model compatibility without changing schema semantics", () => + it.effect("applies model compatibility to nested schemas", () => Effect.gen(function* () { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) @@ -294,7 +428,7 @@ describe("tool schema projections", () => { { type: "object", properties: { - tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + tuple: { type: "array", items: {} }, linked: { $ref: "#/$defs/Linked", description: "keep me" }, }, },