fix(provider): normalize kimi tool schemas for mfjs
This commit is contained in:
parent
fab2133129
commit
ba5dd2f962
4 changed files with 768 additions and 151 deletions
438
packages/opencode/src/provider/mfjs.ts
Normal file
438
packages/opencode/src/provider/mfjs.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
export * as MFJS from "./mfjs"
|
||||||
|
|
||||||
|
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||||
|
|
||||||
|
// MFJS specification and reference validator: https://github.com/MoonshotAI/walle
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
const TYPES = new Set(["string", "number", "boolean", "integer", "object", "array", "null"])
|
||||||
|
const RESERVED_PROPERTIES = new Set(["$defs", "$ref", "anyOf", "required", "additionalProperties"])
|
||||||
|
const MAX_ANY_OF = 500
|
||||||
|
const MAX_ENUM = 1000
|
||||||
|
|
||||||
|
// Moonshot accepts MFJS, a strict and intentionally smaller JSON Schema dialect.
|
||||||
|
// Lower broader schemas here so one incompatible tool cannot reject the whole request.
|
||||||
|
export function sanitize(value: unknown): JSONSchema7 {
|
||||||
|
const root = isPlainObject(value) ? value : {}
|
||||||
|
const result = sanitizeNode(root, root, true)
|
||||||
|
if (result.type === "object") return result as JSONSchema7
|
||||||
|
if (typeof result.$ref === "string" && refIsObject(result.$ref, root)) return result as JSONSchema7
|
||||||
|
if (Array.isArray(result.anyOf)) {
|
||||||
|
const branches = result.anyOf.filter(isPlainObject)
|
||||||
|
if (branches.length > 0 && branches.every((branch) => branch.type === "object")) {
|
||||||
|
return withMetadata(mergeObjectUnion(branches), result) as JSONSchema7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withMetadata({ type: "object", properties: {} }, result) as JSONSchema7
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRecord {
|
||||||
|
if (!isPlainObject(value)) return { type: isRoot ? "object" : "string" }
|
||||||
|
|
||||||
|
const source = lowerAllOf(value, root)
|
||||||
|
const ref = rewriteRef(source.$ref)
|
||||||
|
if (ref && refExists(ref, root)) return withRootMetadata({ $ref: ref }, source, root, isRoot)
|
||||||
|
|
||||||
|
const typeList = Array.isArray(source.type)
|
||||||
|
? source.type.filter((item): item is string => typeof item === "string" && TYPES.has(item))
|
||||||
|
: []
|
||||||
|
const scalarType = typeof source.type === "string" && TYPES.has(source.type) ? source.type : undefined
|
||||||
|
const enumValues = enumValuesOf("const" in source ? [source.const] : source.enum)
|
||||||
|
const variants = Array.isArray(source.anyOf) ? source.anyOf : Array.isArray(source.oneOf) ? source.oneOf : undefined
|
||||||
|
|
||||||
|
if (variants || typeList.length > 1 || source.nullable === true) {
|
||||||
|
const unionBase = Object.fromEntries(
|
||||||
|
Object.entries(source).filter(
|
||||||
|
([key]) => !["anyOf", "oneOf", "allOf", "nullable", "$defs", "definitions", "$id"].includes(key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const branches = variants
|
||||||
|
? variants
|
||||||
|
.slice(0, MAX_ANY_OF)
|
||||||
|
.filter(isPlainObject)
|
||||||
|
.map((branch) => intersect(unionBase, branch))
|
||||||
|
.filter((branch): branch is JsonRecord => branch !== undefined)
|
||||||
|
: (typeList.length > 0 ? typeList : [scalarType ?? inferType(source, groupEnum(enumValues))]).flatMap((type) => {
|
||||||
|
const base = Object.fromEntries(
|
||||||
|
Object.entries(unionBase).filter(([key]) => !["type", "enum", "const"].includes(key)),
|
||||||
|
)
|
||||||
|
const matching = enumValues.filter((item) => typeMatches(item, type))
|
||||||
|
if (enumValues.length > 0 && matching.length === 0) return []
|
||||||
|
return [{ ...base, type, ...(matching.length > 0 ? { enum: matching } : {}) }]
|
||||||
|
})
|
||||||
|
if (source.nullable === true && !branches.some((branch) => branch.type === "null")) branches.push({ type: "null" })
|
||||||
|
|
||||||
|
const sanitized = branches
|
||||||
|
.map((branch) => sanitizeNode(branch, root))
|
||||||
|
.flatMap((branch) => (Object.keys(branch).length === 1 && Array.isArray(branch.anyOf) ? branch.anyOf : [branch]))
|
||||||
|
const unique = [...new Map(sanitized.map((branch) => [JSON.stringify(branch), branch])).values()]
|
||||||
|
const result =
|
||||||
|
unique.length === 1
|
||||||
|
? (unique[0] ?? {})
|
||||||
|
: unique.length > 1
|
||||||
|
? { anyOf: unique.slice(0, MAX_ANY_OF) }
|
||||||
|
: sanitizeNode(unionBase, root)
|
||||||
|
return withRootMetadata(result, source, root, isRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitType =
|
||||||
|
typeof source.type === "string" && TYPES.has(source.type)
|
||||||
|
? source.type
|
||||||
|
: typeList.length === 1
|
||||||
|
? typeList[0]
|
||||||
|
: undefined
|
||||||
|
const matchingEnum = explicitType ? enumValues.filter((item) => typeMatches(item, explicitType)) : enumValues
|
||||||
|
const enumGroups = groupEnum(matchingEnum.length > 0 ? matchingEnum : enumValues)
|
||||||
|
|
||||||
|
if (enumGroups.length > 1 || (enumGroups.length === 1 && enumValues.length > 0 && matchingEnum.length === 0)) {
|
||||||
|
const base = Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([key]) => !["type", "enum", "const", "$defs", "definitions", "$id"].includes(key)),
|
||||||
|
)
|
||||||
|
const branches = enumGroups.map((group) => sanitizeNode({ ...base, type: group.type, enum: group.values }, root))
|
||||||
|
const result = branches.length === 1 ? (branches[0] ?? {}) : { anyOf: branches }
|
||||||
|
return withRootMetadata(result, source, root, isRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = explicitType ?? inferType(source, enumGroups)
|
||||||
|
const result: JsonRecord = { type }
|
||||||
|
if (typeof source.description === "string") result.description = source.description
|
||||||
|
if (typeof source.title === "string") result.title = source.title
|
||||||
|
if ("default" in source) result.default = source.default
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
if (isPlainObject(source.properties)) {
|
||||||
|
result.properties = Object.fromEntries(
|
||||||
|
Object.entries(source.properties)
|
||||||
|
.filter(([name]) => !RESERVED_PROPERTIES.has(name) && !name.includes("/"))
|
||||||
|
.map(([name, schema]) => [name, sanitizeNode(schema, root)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const properties = isPlainObject(result.properties) ? result.properties : undefined
|
||||||
|
if (Array.isArray(source.required) && properties) {
|
||||||
|
const required = [
|
||||||
|
...new Set(
|
||||||
|
source.required.filter(
|
||||||
|
(item): item is string => typeof item === "string" && item.length > 0 && Object.hasOwn(properties, item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if (required.length > 0) result.required = required
|
||||||
|
}
|
||||||
|
if (typeof source.additionalProperties === "boolean") result.additionalProperties = source.additionalProperties
|
||||||
|
if (isPlainObject(source.additionalProperties)) {
|
||||||
|
result.additionalProperties =
|
||||||
|
Object.keys(source.additionalProperties).length === 0 ? {} : sanitizeNode(source.additionalProperties, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "array") {
|
||||||
|
const items = Array.isArray(source.items)
|
||||||
|
? source.items.filter(isPlainObject)
|
||||||
|
: isPlainObject(source.items)
|
||||||
|
? [source.items]
|
||||||
|
: Array.isArray(source.prefixItems)
|
||||||
|
? source.prefixItems.filter(isPlainObject)
|
||||||
|
: []
|
||||||
|
const sanitized = [
|
||||||
|
...new Map(items.map((item) => sanitizeNode(item, root)).map((item) => [JSON.stringify(item), item])).values(),
|
||||||
|
]
|
||||||
|
if (sanitized.length === 1) result.items = sanitized[0]
|
||||||
|
if (sanitized.length > 1) result.items = { anyOf: sanitized.slice(0, MAX_ANY_OF) }
|
||||||
|
copyRange(result, source, "minItems", "maxItems", true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "string") copyRange(result, source, "minLength", "maxLength", true, true)
|
||||||
|
if (type === "number" || type === "integer") {
|
||||||
|
const minimum =
|
||||||
|
typeof source.minimum === "number"
|
||||||
|
? source.minimum
|
||||||
|
: type === "integer" && typeof source.exclusiveMinimum === "number"
|
||||||
|
? Math.floor(source.exclusiveMinimum) + 1
|
||||||
|
: undefined
|
||||||
|
const maximum =
|
||||||
|
typeof source.maximum === "number"
|
||||||
|
? source.maximum
|
||||||
|
: type === "integer" && typeof source.exclusiveMaximum === "number"
|
||||||
|
? Math.ceil(source.exclusiveMaximum) - 1
|
||||||
|
: undefined
|
||||||
|
copyRange(result, { minimum, maximum }, "minimum", "maximum", type === "integer", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["string", "number", "integer", "boolean", "null"].includes(type)) {
|
||||||
|
const values = (matchingEnum.length > 0 ? matchingEnum : (enumGroups[0]?.values ?? [])).filter((item) =>
|
||||||
|
typeMatches(item, type),
|
||||||
|
)
|
||||||
|
if (values.length > 0) result.enum = values
|
||||||
|
}
|
||||||
|
|
||||||
|
return withRootMetadata(result, source, root, isRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRootMetadata(result: JsonRecord, source: JsonRecord, root: JsonRecord, isRoot: boolean) {
|
||||||
|
if (!isRoot) return result
|
||||||
|
if (typeof source.$id === "string") result.$id = source.$id
|
||||||
|
|
||||||
|
const sanitized = Object.fromEntries(
|
||||||
|
Object.entries(definitions(root))
|
||||||
|
.filter(([name, schema]) => name.length > 0 && !name.includes("/") && isPlainObject(schema))
|
||||||
|
.map(([name, schema]) => [name, sanitizeNode(schema, root)]),
|
||||||
|
)
|
||||||
|
if (Object.keys(sanitized).length > 0) result.$defs = sanitized
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMetadata(result: JsonRecord, source: JsonRecord) {
|
||||||
|
if (typeof source.$id === "string") result.$id = source.$id
|
||||||
|
if (isPlainObject(source.$defs)) result.$defs = source.$defs
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeObjectUnion(branches: JsonRecord[]) {
|
||||||
|
const propertyNames = new Set(
|
||||||
|
branches.flatMap((branch) => (isPlainObject(branch.properties) ? Object.keys(branch.properties) : [])),
|
||||||
|
)
|
||||||
|
const properties = Object.fromEntries(
|
||||||
|
[...propertyNames].map((name) => {
|
||||||
|
const schemas = [
|
||||||
|
...new Map(
|
||||||
|
branches
|
||||||
|
.flatMap((branch) =>
|
||||||
|
isPlainObject(branch.properties) && isPlainObject(branch.properties[name])
|
||||||
|
? [branch.properties[name]]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.map((schema) => [JSON.stringify(schema), schema]),
|
||||||
|
).values(),
|
||||||
|
]
|
||||||
|
return [name, schemas.length === 1 ? schemas[0] : { anyOf: schemas.slice(0, MAX_ANY_OF) }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const first = Array.isArray(branches[0]?.required)
|
||||||
|
? branches[0].required.filter((item): item is string => typeof item === "string")
|
||||||
|
: []
|
||||||
|
const required = branches
|
||||||
|
.map(
|
||||||
|
(branch) =>
|
||||||
|
new Set(Array.isArray(branch.required) ? branch.required.filter((item) => typeof item === "string") : []),
|
||||||
|
)
|
||||||
|
.reduce((common, branch) => common.filter((name) => branch.has(name)), first)
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
properties,
|
||||||
|
...(required.length > 0 ? { required } : {}),
|
||||||
|
...(branches.every((branch) => branch.additionalProperties === false) ? { additionalProperties: false } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowerAllOf(source: JsonRecord, root: JsonRecord) {
|
||||||
|
if (!Array.isArray(source.allOf)) return source
|
||||||
|
const base = Object.fromEntries(Object.entries(source).filter(([key]) => key !== "allOf"))
|
||||||
|
return source.allOf.filter(isPlainObject).reduce((result, branch) => {
|
||||||
|
const ref = rewriteRef(branch.$ref)
|
||||||
|
const target = ref ? resolveRef(ref, root) : undefined
|
||||||
|
const next = isPlainObject(target) ? { ...target, ...branch, $ref: undefined } : branch
|
||||||
|
return intersect(result, next) ?? merge(result, next)
|
||||||
|
}, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersect(left: JsonRecord, right: JsonRecord): JsonRecord | undefined {
|
||||||
|
const leftTypes = schemaTypes(left.type)
|
||||||
|
const rightTypes = schemaTypes(right.type)
|
||||||
|
const types =
|
||||||
|
leftTypes.length > 0 && rightTypes.length > 0
|
||||||
|
? [
|
||||||
|
...new Set([
|
||||||
|
...leftTypes.filter((type) => rightTypes.includes(type)),
|
||||||
|
...((leftTypes.includes("integer") && rightTypes.includes("number")) ||
|
||||||
|
(leftTypes.includes("number") && rightTypes.includes("integer"))
|
||||||
|
? ["integer"]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: leftTypes.length > 0
|
||||||
|
? leftTypes
|
||||||
|
: rightTypes
|
||||||
|
if (leftTypes.length > 0 && rightTypes.length > 0 && types.length === 0) return
|
||||||
|
|
||||||
|
const result = merge(left, right)
|
||||||
|
const leftEnum = enumValuesOf("const" in left ? [left.const] : left.enum)
|
||||||
|
const rightEnum = enumValuesOf("const" in right ? [right.const] : right.enum)
|
||||||
|
if (leftEnum.length > 0 && rightEnum.length > 0) {
|
||||||
|
const intersection = leftEnum.filter((item) => rightEnum.some((other) => Object.is(item, other)))
|
||||||
|
if (intersection.length === 0) return
|
||||||
|
delete result.const
|
||||||
|
result.enum = intersection
|
||||||
|
}
|
||||||
|
if (types.length === 1) result.type = types[0]
|
||||||
|
if (types.length > 1) result.type = types
|
||||||
|
if (Array.isArray(left.enum) && Array.isArray(right.enum) && Array.isArray(result.enum) && result.enum.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [minimumKey, maximumKey] of [
|
||||||
|
["minimum", "maximum"],
|
||||||
|
["minLength", "maxLength"],
|
||||||
|
["minItems", "maxItems"],
|
||||||
|
] as const) {
|
||||||
|
const minimums = [left[minimumKey], right[minimumKey]].filter(
|
||||||
|
(item): item is number => typeof item === "number" && Number.isFinite(item),
|
||||||
|
)
|
||||||
|
const maximums = [left[maximumKey], right[maximumKey]].filter(
|
||||||
|
(item): item is number => typeof item === "number" && Number.isFinite(item),
|
||||||
|
)
|
||||||
|
if (minimums.length > 0) result[minimumKey] = Math.max(...minimums)
|
||||||
|
if (maximums.length > 0) result[maximumKey] = Math.min(...maximums)
|
||||||
|
if (
|
||||||
|
typeof result[minimumKey] === "number" &&
|
||||||
|
typeof result[maximumKey] === "number" &&
|
||||||
|
result[minimumKey] > result[maximumKey]
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(left: JsonRecord, right: JsonRecord): JsonRecord {
|
||||||
|
const result = { ...left, ...right }
|
||||||
|
if (isPlainObject(left.properties) || isPlainObject(right.properties)) {
|
||||||
|
const names = new Set([
|
||||||
|
...Object.keys(isPlainObject(left.properties) ? left.properties : {}),
|
||||||
|
...Object.keys(isPlainObject(right.properties) ? right.properties : {}),
|
||||||
|
])
|
||||||
|
result.properties = Object.fromEntries(
|
||||||
|
[...names].map((name) => {
|
||||||
|
const a =
|
||||||
|
isPlainObject(left.properties) && isPlainObject(left.properties[name]) ? left.properties[name] : undefined
|
||||||
|
const b =
|
||||||
|
isPlainObject(right.properties) && isPlainObject(right.properties[name]) ? right.properties[name] : undefined
|
||||||
|
return [name, a && b ? (intersect(a, b) ?? b) : (b ?? a)]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Array.isArray(left.required) || Array.isArray(right.required)) {
|
||||||
|
result.required = [
|
||||||
|
...new Set([
|
||||||
|
...(Array.isArray(left.required) ? left.required : []),
|
||||||
|
...(Array.isArray(right.required) ? right.required : []),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (left.additionalProperties === false || right.additionalProperties === false) result.additionalProperties = false
|
||||||
|
if (Array.isArray(left.enum) && Array.isArray(right.enum)) {
|
||||||
|
const rightEnum = right.enum
|
||||||
|
result.enum = left.enum.filter((item) => rightEnum.some((other) => Object.is(item, other)))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRef(value: unknown) {
|
||||||
|
if (typeof value !== "string") return
|
||||||
|
const ref = value.replace("#/definitions/", "#/$defs/")
|
||||||
|
if (ref === "#" || ref.startsWith("#/$defs/")) return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
function refExists(ref: string, root: JsonRecord) {
|
||||||
|
if (ref === "#") return true
|
||||||
|
return isPlainObject(resolveRef(ref, root))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refIsObject(ref: string, root: JsonRecord) {
|
||||||
|
const target = resolveRef(ref, root)
|
||||||
|
if (!isPlainObject(target)) return false
|
||||||
|
const lowered = lowerAllOf(target, root)
|
||||||
|
return lowered.type === "object" || isPlainObject(lowered.properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRef(ref: string, root: JsonRecord): unknown {
|
||||||
|
if (ref === "#") return root
|
||||||
|
return ref
|
||||||
|
.slice("#/$defs/".length)
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||||
|
.reduce<unknown>((current, part, index) => {
|
||||||
|
if (index === 0) return definitions(root)[part]
|
||||||
|
if (!isPlainObject(current)) return
|
||||||
|
return current[part]
|
||||||
|
}, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function definitions(root: JsonRecord) {
|
||||||
|
return {
|
||||||
|
...(isPlainObject(root.definitions) ? root.definitions : {}),
|
||||||
|
...(isPlainObject(root.$defs) ? root.$defs : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaTypes(value: unknown) {
|
||||||
|
if (typeof value === "string" && TYPES.has(value)) return [value]
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return [...new Set(value.filter((item): item is string => typeof item === "string" && TYPES.has(item)))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumValuesOf(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return [
|
||||||
|
...new Map(value.filter((item) => valueType(item)).map((item) => [JSON.stringify(item), item])).values(),
|
||||||
|
].slice(0, MAX_ENUM)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupEnum(values: unknown[]) {
|
||||||
|
const hasDecimal = values.some((item) => typeof item === "number" && !Number.isInteger(item))
|
||||||
|
return values.reduce<{ type: string; values: unknown[] }[]>((groups, item) => {
|
||||||
|
const actual = valueType(item)
|
||||||
|
if (!actual) return groups
|
||||||
|
const type = hasDecimal && actual === "integer" ? "number" : actual
|
||||||
|
const group = groups.find((entry) => entry.type === type)
|
||||||
|
if (group) group.values.push(item)
|
||||||
|
else groups.push({ type, values: [item] })
|
||||||
|
return groups
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueType(value: unknown) {
|
||||||
|
if (value === null) return "null"
|
||||||
|
if (typeof value === "string" || typeof value === "boolean") return typeof value
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? "integer" : "number"
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeMatches(value: unknown, type: string) {
|
||||||
|
const actual = valueType(value)
|
||||||
|
if (type === "number") return actual === "number" || actual === "integer"
|
||||||
|
return actual === type
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferType(source: JsonRecord, enumGroups: { type: string; values: unknown[] }[]) {
|
||||||
|
if (["properties", "required", "additionalProperties"].some((key) => key in source)) return "object"
|
||||||
|
if (["items", "prefixItems", "minItems", "maxItems"].some((key) => key in source)) return "array"
|
||||||
|
if (["minLength", "maxLength", "format", "pattern"].some((key) => key in source)) return "string"
|
||||||
|
if (["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in source)) {
|
||||||
|
return "number"
|
||||||
|
}
|
||||||
|
return enumGroups[0]?.type ?? "string"
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRange(
|
||||||
|
result: JsonRecord,
|
||||||
|
source: JsonRecord,
|
||||||
|
minimumKey: "minItems" | "minLength" | "minimum",
|
||||||
|
maximumKey: "maxItems" | "maxLength" | "maximum",
|
||||||
|
integer: boolean,
|
||||||
|
nonNegative: boolean,
|
||||||
|
) {
|
||||||
|
const valid = (value: unknown) =>
|
||||||
|
typeof value === "number" &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
(!integer || Number.isInteger(value)) &&
|
||||||
|
(!nonNegative || value >= 0)
|
||||||
|
const minimum = valid(source[minimumKey]) ? source[minimumKey] : undefined
|
||||||
|
const maximum = valid(source[maximumKey]) ? source[maximumKey] : undefined
|
||||||
|
if (typeof minimum === "number" && typeof maximum === "number" && minimum > maximum) return
|
||||||
|
if (minimum !== undefined) result[minimumKey] = minimum
|
||||||
|
if (maximum !== undefined) result[maximumKey] = maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import type { ModelMessage, ToolResultPart } from "ai"
|
||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||||
import type * as Provider from "./provider"
|
import type * as Provider from "./provider"
|
||||||
|
import { MFJS } from "./mfjs"
|
||||||
import type * as ModelsDev from "@opencode-ai/core/models-dev"
|
import type * as ModelsDev from "@opencode-ai/core/models-dev"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
|
||||||
|
|
@ -1463,21 +1464,7 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
|
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
|
||||||
const sanitizeMoonshot = (obj: unknown): unknown => {
|
schema = MFJS.sanitize(schema)
|
||||||
if (obj === null || typeof obj !== "object") return obj
|
|
||||||
if (Array.isArray(obj)) return obj.map(sanitizeMoonshot)
|
|
||||||
// Moonshot expands $ref before validation and rejects sibling keywords like description on the same node.
|
|
||||||
if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref }
|
|
||||||
const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)]))
|
|
||||||
// MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items.
|
|
||||||
if (Array.isArray(result.items)) result.items = result.items[0] ?? {}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = sanitizeMoonshot(schema)
|
|
||||||
if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
|
|
||||||
schema = sanitized
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert integer enums to string enums for Google/Gemini
|
// Convert integer enums to string enums for Google/Gemini
|
||||||
|
|
|
||||||
311
packages/opencode/test/provider/mfjs.test.ts
Normal file
311
packages/opencode/test/provider/mfjs.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { MFJS } from "@/provider/mfjs"
|
||||||
|
|
||||||
|
describe("MFJS.sanitize", () => {
|
||||||
|
test("removes siblings from references while preserving definitions", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: {
|
||||||
|
$ref: "#/$defs/Value",
|
||||||
|
description: "Moonshot rejects siblings after expanding a reference.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
Value: { type: "object", description: "The referenced description remains here." },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { value: { $ref: "#/$defs/Value" } },
|
||||||
|
$defs: {
|
||||||
|
Value: { type: "object", description: "The referenced description remains here." },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("repairs enum types that contradict their values", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
operation: { type: "object", enum: ["move", "copy"] },
|
||||||
|
},
|
||||||
|
required: ["operation"],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
operation: { type: "string", enum: ["move", "copy"] },
|
||||||
|
},
|
||||||
|
required: ["operation"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lowers type arrays and nullable enums to anyOf", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
operation: { type: ["string", "null"], enum: ["move", null] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
operation: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "string", enum: ["move"] },
|
||||||
|
{ type: "null", enum: [null] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes single-value type arrays", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: { value: { type: ["null"] } },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { value: { type: "null" } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves scalar types when lowering nullable schemas", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: { value: { type: "string", nullable: true } },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("intersects parent constraints into anyOf branches", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["move"],
|
||||||
|
anyOf: [{ type: "string" }, { type: "null" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { value: { type: "string", enum: ["move"] } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("infers missing types and filters dangling required fields", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
properties: {
|
||||||
|
mode: { enum: ["fast", "safe"] },
|
||||||
|
options: { properties: { retries: { type: "integer" } } },
|
||||||
|
values: { items: { type: "number" } },
|
||||||
|
},
|
||||||
|
required: ["mode", "missing"],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
mode: { type: "string", enum: ["fast", "safe"] },
|
||||||
|
options: { type: "object", properties: { retries: { type: "integer" } } },
|
||||||
|
values: { type: "array", items: { type: "number" } },
|
||||||
|
},
|
||||||
|
required: ["mode"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("converts oneOf and strips unsupported keywords", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
properties: {
|
||||||
|
value: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "string", format: "uri" },
|
||||||
|
{ type: "integer", multipleOf: 2 },
|
||||||
|
],
|
||||||
|
examples: ["https://example.com"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unevaluatedProperties: false,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { anyOf: [{ type: "string" }, { type: "integer" }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("widens heterogeneous tuples without narrowing item types", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: [{ type: "string" }, { type: "number" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("collapses homogeneous tuple items", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: [{ type: "number" }, { type: "number" }],
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "number" },
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forces mixed root unions to an object parameter schema", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
anyOf: [{ type: "object", properties: { value: { type: "string" } } }, { type: "string" }],
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "object", properties: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flattens allOf object schemas", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
allOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { source: { type: "string" } },
|
||||||
|
required: ["source"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { destination: { type: "string" } },
|
||||||
|
required: ["destination"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
source: { type: "string" },
|
||||||
|
destination: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["source", "destination"],
|
||||||
|
additionalProperties: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes draft-07 definitions and references", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
operation: {
|
||||||
|
$ref: "#/definitions/Operation",
|
||||||
|
description: "Moonshot rejects siblings next to refs.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
definitions: {
|
||||||
|
Operation: { enum: ["move", "copy"] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { operation: { $ref: "#/$defs/Operation" } },
|
||||||
|
$defs: {
|
||||||
|
Operation: { type: "string", enum: ["move", "copy"] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops references to missing definitions", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: { operation: { $ref: "#/$defs/Missing" } },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { operation: { type: "string" } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps only valid MFJS ranges", () => {
|
||||||
|
expect(
|
||||||
|
MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
score: { type: "number", exclusiveMinimum: -1, exclusiveMaximum: 1 },
|
||||||
|
count: { type: "integer", exclusiveMinimum: -1, exclusiveMaximum: 3 },
|
||||||
|
label: { type: "string", minLength: 5, maxLength: 2 },
|
||||||
|
list: { type: "array", minItems: -1, maxItems: 3 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
score: { type: "number" },
|
||||||
|
count: { type: "integer", minimum: 0, maximum: 2 },
|
||||||
|
label: { type: "string" },
|
||||||
|
list: { type: "array", maxItems: 3 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("is idempotent", () => {
|
||||||
|
const once = MFJS.sanitize({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
operation: { type: ["string", "null"], enum: ["move", null] },
|
||||||
|
values: { type: "array", items: [{ type: "string" }, { type: "number" }] },
|
||||||
|
},
|
||||||
|
definitions: {
|
||||||
|
Metadata: { type: "object", properties: { label: { type: "string", format: "uri" } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(MFJS.sanitize(once)).toEqual(once)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1528,148 +1528,29 @@ describe("ProviderTransform.schema - openai supported schema subset", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.schema - moonshot $ref siblings", () => {
|
describe("ProviderTransform.schema - MFJS selection", () => {
|
||||||
const moonshotModel = {
|
const models = [
|
||||||
providerID: "moonshotai",
|
["Moonshot providers", { providerID: "moonshotai", api: { id: "kimi-k2" } }],
|
||||||
api: {
|
["Kimi API IDs", { providerID: "openrouter", api: { id: "moonshotai/kimi-k2" } }],
|
||||||
id: "kimi-k2",
|
] as const
|
||||||
},
|
|
||||||
} as any
|
|
||||||
|
|
||||||
test("removes sibling descriptions from referenced tool parameter schemas", () => {
|
for (const [name, model] of models) {
|
||||||
const schema = {
|
test(`sanitizes ${name}`, () => {
|
||||||
type: "object",
|
expect(
|
||||||
properties: {
|
ProviderTransform.schema(model as Parameters<typeof ProviderTransform.schema>[0], {
|
||||||
deviceType: {
|
|
||||||
description: "Optional. The type of device that captured the screenshot, e.g. mobile or desktop.",
|
|
||||||
enum: ["DEVICE_TYPE_UNSPECIFIED", "MOBILE", "DESKTOP", "TABLET", "AGNOSTIC"],
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
modelId: {
|
|
||||||
description: "Optional. The model to use for generation.",
|
|
||||||
enum: ["MODEL_ID_UNSPECIFIED", "GEMINI_3_PRO", "GEMINI_3_FLASH", "GEMINI_3_1_PRO"],
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
projectId: {
|
|
||||||
description: "Required. The project ID of screens to generate variants for.",
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
description: "Required. The input text used to generate the variants.",
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
selectedScreenIds: {
|
|
||||||
description: "Required. The screen ids of screen to generate variants for.",
|
|
||||||
items: {
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
type: "array",
|
|
||||||
},
|
|
||||||
variantOptions: {
|
|
||||||
$ref: "#/$defs/VariantOptions",
|
|
||||||
description:
|
|
||||||
"Required. The variant options for generation, including the number of variants, creative range, and aspects to focus on.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["projectId", "selectedScreenIds", "prompt", "variantOptions"],
|
|
||||||
$defs: {
|
|
||||||
VariantOptions: {
|
|
||||||
description:
|
|
||||||
"Configuration options for design variant generation. This message captures all parameters used to generate variants, allowing the configuration to be stored, replayed, or analyzed.",
|
|
||||||
properties: {
|
|
||||||
aspects: {
|
|
||||||
description: "Optional. Specific aspects to focus on. If empty, all aspects may be varied.",
|
|
||||||
items: {
|
|
||||||
enum: ["VARIANT_ASPECT_UNSPECIFIED", "LAYOUT", "COLOR_SCHEME", "IMAGES", "TEXT_FONT", "TEXT_CONTENT"],
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
type: "array",
|
|
||||||
},
|
|
||||||
creativeRange: {
|
|
||||||
description: "Optional. Creative range for variations. Default: EXPLORE",
|
|
||||||
enum: ["CREATIVE_RANGE_UNSPECIFIED", "REFINE", "EXPLORE", "REIMAGINE"],
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
variantCount: {
|
|
||||||
description: "Optional. Number of variants to generate (1-5). Default: 3",
|
|
||||||
format: "int32",
|
|
||||||
type: "integer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
type: "object",
|
type: "object",
|
||||||
},
|
properties: {
|
||||||
},
|
operation: { type: "object", enum: ["move", "copy"] },
|
||||||
description: "Request message for GenerateVariants.",
|
},
|
||||||
additionalProperties: false,
|
}),
|
||||||
} as any
|
).toEqual({
|
||||||
|
|
||||||
const result = ProviderTransform.schema(moonshotModel, schema) as any
|
|
||||||
|
|
||||||
expect(result.properties.variantOptions).toEqual({
|
|
||||||
$ref: "#/$defs/VariantOptions",
|
|
||||||
})
|
|
||||||
expect(result.$defs.VariantOptions.description).toBe(schema.$defs.VariantOptions.description)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("also runs for kimi models outside the moonshot provider", () => {
|
|
||||||
const result = ProviderTransform.schema(
|
|
||||||
{
|
|
||||||
providerID: "openrouter",
|
|
||||||
name: "Kimi K2",
|
|
||||||
api: {
|
|
||||||
id: "moonshotai/kimi-k2",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
{
|
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
value: {
|
operation: { type: "string", enum: ["move", "copy"] },
|
||||||
$ref: "#/$defs/Value",
|
|
||||||
description: "Moonshot rejects this sibling after ref expansion.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
$defs: {
|
})
|
||||||
Value: {
|
|
||||||
description: "Referenced schema description stays here.",
|
|
||||||
type: "object",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
) as any
|
|
||||||
|
|
||||||
expect(result.properties.value).toEqual({
|
|
||||||
$ref: "#/$defs/Value",
|
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
test("converts tuple-style array items to a single item schema", () => {
|
|
||||||
const result = ProviderTransform.schema(moonshotModel, {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
codeSpec: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
accessibility: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
renderedSize: {
|
|
||||||
description: "Rendered size [width, height] in px",
|
|
||||||
type: "array",
|
|
||||||
items: [{ type: "number" }, { type: "number" }],
|
|
||||||
minItems: 2,
|
|
||||||
maxItems: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any) as any
|
|
||||||
|
|
||||||
expect(result.properties.codeSpec.properties.accessibility.properties.renderedSize.items).toEqual({
|
|
||||||
type: "number",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue