mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
fix(provider): normalize kimi tool schemas for mfjs
This commit is contained in:
@@ -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 type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type * as Provider from "./provider"
|
||||
import { MFJS } from "./mfjs"
|
||||
import type * as ModelsDev from "@opencode-ai/core/models-dev"
|
||||
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")) {
|
||||
const sanitizeMoonshot = (obj: unknown): unknown => {
|
||||
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
|
||||
}
|
||||
schema = MFJS.sanitize(schema)
|
||||
}
|
||||
|
||||
// Convert integer enums to string enums for Google/Gemini
|
||||
|
||||
@@ -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", () => {
|
||||
const moonshotModel = {
|
||||
providerID: "moonshotai",
|
||||
api: {
|
||||
id: "kimi-k2",
|
||||
},
|
||||
} as any
|
||||
describe("ProviderTransform.schema - MFJS selection", () => {
|
||||
const models = [
|
||||
["Moonshot providers", { providerID: "moonshotai", api: { id: "kimi-k2" } }],
|
||||
["Kimi API IDs", { providerID: "openrouter", api: { id: "moonshotai/kimi-k2" } }],
|
||||
] as const
|
||||
|
||||
test("removes sibling descriptions from referenced tool parameter schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
for (const [name, model] of models) {
|
||||
test(`sanitizes ${name}`, () => {
|
||||
expect(
|
||||
ProviderTransform.schema(model as Parameters<typeof ProviderTransform.schema>[0], {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
description: "Request message for GenerateVariants.",
|
||||
additionalProperties: false,
|
||||
} as any
|
||||
|
||||
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,
|
||||
{
|
||||
properties: {
|
||||
operation: { type: "object", enum: ["move", "copy"] },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
$ref: "#/$defs/Value",
|
||||
description: "Moonshot rejects this sibling after ref expansion.",
|
||||
},
|
||||
operation: { type: "string", enum: ["move", "copy"] },
|
||||
},
|
||||
$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", () => {
|
||||
|
||||
Reference in New Issue
Block a user