mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(ai): preserve Moonshot schema semantics across unions and refs
This commit is contained in:
@@ -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<string, unknown>, map: (value: unknown, key: string) => unknown) =>
|
||||
@@ -34,92 +34,320 @@ const omit = (record: Record<string, unknown>, ...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<string, unknown>) => {
|
||||
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<string, unknown>, 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<string, unknown>) =>
|
||||
"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<string, unknown>): Record<string, unknown> => {
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>) => {
|
||||
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<unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>, 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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> | undefined => {
|
||||
const seen = new Set<string>()
|
||||
const follow = (pointer: string): Record<string, unknown> | undefined => {
|
||||
if (seen.has(pointer) || !pointer.startsWith("#")) return undefined
|
||||
seen.add(pointer)
|
||||
const target =
|
||||
pointer === "#"
|
||||
? root
|
||||
: pointer.startsWith("#/")
|
||||
? pointer
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.reduce<unknown>(
|
||||
(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<string, unknown>, 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 : {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user