From ba5dd2f9620d0b3fb26d7f4c545e616f9d3b8d6e Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 13:22:34 +0200 Subject: [PATCH 1/6] fix(provider): normalize kimi tool schemas for mfjs --- packages/opencode/src/provider/mfjs.ts | 438 ++++++++++++++++++ packages/opencode/src/provider/transform.ts | 17 +- packages/opencode/test/provider/mfjs.test.ts | 311 +++++++++++++ .../opencode/test/provider/transform.test.ts | 153 +----- 4 files changed, 768 insertions(+), 151 deletions(-) create mode 100644 packages/opencode/src/provider/mfjs.ts create mode 100644 packages/opencode/test/provider/mfjs.test.ts diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts new file mode 100644 index 0000000000..8a72724a03 --- /dev/null +++ b/packages/opencode/src/provider/mfjs.ts @@ -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 + +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((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) +} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d6f817b50e..c9cea560cd 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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 diff --git a/packages/opencode/test/provider/mfjs.test.ts b/packages/opencode/test/provider/mfjs.test.ts new file mode 100644 index 0000000000..bee3a9f4d2 --- /dev/null +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -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) + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c5cea6b797..c02798455b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -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[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", () => { From fa041090f7b2bcfc807de1d57efd39a233513add Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 17:08:52 +0200 Subject: [PATCH 2/6] fix(provider --- packages/opencode/src/provider/mfjs.ts | 374 ++++++++++++++++--- packages/opencode/test/provider/mfjs.test.ts | 200 ++++++++++ 2 files changed, 521 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index 8a72724a03..8fb4e0ebf6 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -5,34 +5,81 @@ import type { JSONSchema7 } from "@ai-sdk/provider" // MFJS specification and reference validator: https://github.com/MoonshotAI/walle type JsonRecord = Record +type Context = { + root: JsonRecord + definitions: JsonRecord + properties: number +} +type Position = { + depth: number + definition: boolean + root: boolean +} 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 +const MAX_DEPTH = 30 +const MAX_PROPERTIES = 3000 +const MAX_SCHEMA_SIZE = 120_000 // 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 + const definitions = collectDefinitions(root) + const context = { root, definitions: referencedDefinitions(root, definitions), properties: 0 } + const lowered = sanitizeNode(root, context, { depth: 0, definition: false, root: true }) + const rooted = forceObjectRoot(lowered, context) + const referenced = pruneInvalidRefs(rooted, rooted) + const terminating = ensureTermination(forceObjectRoot(referenced, context)) + const depthBounded = + schemaDepth(terminating, terminating, new Set()) <= MAX_DEPTH ? terminating : emptyRoot(terminating) + return fitSchemaSize(forceObjectRoot(depthBounded, context)) as JSONSchema7 } -function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRecord { - if (!isPlainObject(value)) return { type: isRoot ? "object" : "string" } +function forceObjectRoot(result: JsonRecord, context: Context) { + if (result.type === "object") return result + if (typeof result.$ref === "string" && refIsObject(result.$ref, context)) return result + if (Array.isArray(result.anyOf)) { + const branches = result.anyOf.filter(isPlainObject).map((branch) => { + if (branch.type === "object" || typeof branch.$ref !== "string" || !refIsObject(branch.$ref, context)) { + return branch + } + const target = resolveRef(branch.$ref, context) + return isPlainObject(target) + ? sanitizeNode(target, context, { depth: 0, definition: false, root: false }) + : branch + }) + if (branches.length > 0 && branches.every((branch) => branch.type === "object")) { + return withMetadata(mergeObjectUnion(branches), result) + } + } + return withMetadata({ type: "object", properties: {} }, result) +} - const source = lowerAllOf(value, root) +function sanitizeNode(value: unknown, context: Context, position: Position): JsonRecord { + if (position.depth >= MAX_DEPTH) return {} + if (value === true || value === false) return position.root ? { type: "object", properties: {} } : {} + if (!isPlainObject(value)) return position.root ? { type: "object", properties: {} } : {} + if (Object.keys(value).length === 0) return position.root ? { type: "object", properties: {} } : {} + + const source = lowerAllOf(value, context) const ref = rewriteRef(source.$ref) - if (ref && refExists(ref, root)) return withRootMetadata({ $ref: ref }, source, root, isRoot) + if (ref && refExists(ref, context)) { + const siblings = Object.fromEntries( + Object.entries(source).filter(([key]) => !["$ref", "description", "title", "default"].includes(key)), + ) + if (Object.keys(siblings).length === 0) return withRootMetadata({ $ref: ref }, source, context, position) + if (Object.keys(siblings).length === 1 && siblings.nullable === true) { + return withRootMetadata({ anyOf: [{ $ref: ref }, { type: "null" }] }, source, context, position) + } + const target = resolveRef(ref, context) + if (isPlainObject(target)) { + return sanitizeNode({ ...target, ...siblings }, context, position) + } + } const typeList = Array.isArray(source.type) ? source.type.filter((item): item is string => typeof item === "string" && TYPES.has(item)) @@ -64,7 +111,7 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec if (source.nullable === true && !branches.some((branch) => branch.type === "null")) branches.push({ type: "null" }) const sanitized = branches - .map((branch) => sanitizeNode(branch, root)) + .map((branch) => sanitizeNode(branch, context, child(position))) .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 = @@ -72,8 +119,8 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec ? (unique[0] ?? {}) : unique.length > 1 ? { anyOf: unique.slice(0, MAX_ANY_OF) } - : sanitizeNode(unionBase, root) - return withRootMetadata(result, source, root, isRoot) + : sanitizeNode(unionBase, context, position) + return withRootMetadata(result, source, context, position) } const explicitType = @@ -89,23 +136,31 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec 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 branches = enumGroups.map((group) => + sanitizeNode({ ...base, type: group.type, enum: group.values }, context, child(position)), + ) const result = branches.length === 1 ? (branches[0] ?? {}) : { anyOf: branches } - return withRootMetadata(result, source, root, isRoot) + return withRootMetadata(result, source, context, position) } 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 ("default" in source && !position.definition) result.default = source.default if (type === "object") { if (isPlainObject(source.properties)) { + const remaining = Math.max(0, MAX_PROPERTIES - context.properties) + const entries = Object.entries(source.properties) + .filter( + ([name]) => + name.length > 0 && !RESERVED_PROPERTIES.has(name) && (!position.definition || !name.includes("/")), + ) + .slice(0, remaining) + context.properties += entries.length result.properties = Object.fromEntries( - Object.entries(source.properties) - .filter(([name]) => !RESERVED_PROPERTIES.has(name) && !name.includes("/")) - .map(([name, schema]) => [name, sanitizeNode(schema, root)]), + entries.map(([name, schema]) => [name, sanitizeNode(schema, context, child(position))]), ) } const properties = isPlainObject(result.properties) ? result.properties : undefined @@ -122,7 +177,9 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec 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) + Object.keys(source.additionalProperties).length === 0 + ? {} + : sanitizeNode(source.additionalProperties, context, child(position)) } } @@ -135,7 +192,9 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec ? source.prefixItems.filter(isPlainObject) : [] const sanitized = [ - ...new Map(items.map((item) => sanitizeNode(item, root)).map((item) => [JSON.stringify(item), item])).values(), + ...new Map( + items.map((item) => sanitizeNode(item, context, child(position))).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) } @@ -144,18 +203,20 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec 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 + const minimums = [ + ...(typeof source.minimum === "number" ? [source.minimum] : []), + ...(type === "integer" && typeof source.exclusiveMinimum === "number" + ? [Math.floor(source.exclusiveMinimum) + 1] + : []), + ] + const maximums = [ + ...(typeof source.maximum === "number" ? [source.maximum] : []), + ...(type === "integer" && typeof source.exclusiveMaximum === "number" + ? [Math.ceil(source.exclusiveMaximum) - 1] + : []), + ] + const minimum = minimums.length > 0 ? Math.max(...minimums) : undefined + const maximum = maximums.length > 0 ? Math.min(...maximums) : undefined copyRange(result, { minimum, maximum }, "minimum", "maximum", type === "integer", false) } @@ -166,17 +227,17 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec if (values.length > 0) result.enum = values } - return withRootMetadata(result, source, root, isRoot) + return withRootMetadata(result, source, context, position) } -function withRootMetadata(result: JsonRecord, source: JsonRecord, root: JsonRecord, isRoot: boolean) { - if (!isRoot) return result +function withRootMetadata(result: JsonRecord, source: JsonRecord, context: Context, position: Position) { + if (!position.root) return result if (typeof source.$id === "string") result.$id = source.$id const sanitized = Object.fromEntries( - Object.entries(definitions(root)) + Object.entries(context.definitions) .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isPlainObject(schema)) - .map(([name, schema]) => [name, sanitizeNode(schema, root)]), + .map(([name, schema]) => [name, sanitizeNode(schema, context, { depth: 0, definition: true, root: false })]), ) if (Object.keys(sanitized).length > 0) result.$defs = sanitized return result @@ -188,6 +249,188 @@ function withMetadata(result: JsonRecord, source: JsonRecord) { return result } +function child(position: Position): Position { + return { depth: position.depth + 1, definition: position.definition, root: false } +} + +function pruneInvalidRefs(schema: JsonRecord, root: JsonRecord): JsonRecord { + if (typeof schema.$ref === "string" && !resolveOutputRef(schema.$ref, root)) return {} + + const result = { ...schema } + if (isPlainObject(schema.properties)) { + result.properties = Object.fromEntries( + Object.entries(schema.properties).map(([name, property]) => [ + name, + isPlainObject(property) ? pruneInvalidRefs(property, root) : {}, + ]), + ) + } + if (isPlainObject(schema.items)) result.items = pruneInvalidRefs(schema.items, root) + if (isPlainObject(schema.additionalProperties)) { + result.additionalProperties = pruneInvalidRefs(schema.additionalProperties, root) + } + if (Array.isArray(schema.anyOf)) { + const branches = schema.anyOf.filter(isPlainObject).map((branch) => pruneInvalidRefs(branch, root)) + if (branches.length === 1) return withMetadata(branches[0] ?? {}, result) + result.anyOf = branches + } + if (isPlainObject(schema.$defs)) { + result.$defs = Object.fromEntries( + Object.entries(schema.$defs).map(([name, definition]) => [ + name, + isPlainObject(definition) ? pruneInvalidRefs(definition, root) : {}, + ]), + ) + } + return result +} + +function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { + if (ref === "#") return root + if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return + const parts = ref + .slice("#/$defs/".length) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + return parts.reduce((current, part, index) => { + const value = index === 0 ? (isPlainObject(root.$defs) ? root.$defs[part] : undefined) : current?.[part] + return isPlainObject(value) ? value : undefined + }, undefined) +} + +function ensureTermination(schema: JsonRecord) { + if (terminates(schema, schema, new Set())) return schema + if (schema.type === "object") { + const { required: _, ...result } = schema + return result + } + if (schema.type === "array") { + const { items: _, ...result } = schema + return result + } + return emptyRoot(schema) +} + +function emptyRoot(source: JsonRecord) { + return { + type: "object", + properties: {}, + ...(typeof source.$id === "string" ? { $id: source.$id } : {}), + } +} + +function terminates(schema: JsonRecord, root: JsonRecord, refs: Set): boolean { + const types = schemaTypes(schema.type) + if (types.some((type) => type !== "object" && type !== "array")) return true + + if (types.includes("array")) { + if (!isPlainObject(schema.items) || Object.keys(schema.items).length === 0) return true + if (terminates(schema.items, root, new Set(refs))) return true + } + + if (types.includes("object")) { + const required = Array.isArray(schema.required) + ? schema.required.filter((item): item is string => typeof item === "string") + : [] + const properties = isPlainObject(schema.properties) ? schema.properties : undefined + if (required.length === 0 || !properties || Object.keys(properties).length === 0) return true + if ( + required.some((name) => { + const property = properties[name] + return isPlainObject(property) && terminates(property, root, new Set(refs)) + }) + ) { + return true + } + } + + if (Array.isArray(schema.anyOf)) { + if (schema.anyOf.some((branch) => isPlainObject(branch) && terminates(branch, root, new Set(refs)))) return true + } + + if (typeof schema.$ref === "string") { + if (refs.has(schema.$ref)) return false + const target = resolveOutputRef(schema.$ref, root) + if (!target) return false + const next = new Set(refs) + next.add(schema.$ref) + return terminates(target, root, next) + } + return Object.keys(schema).length === 0 +} + +function fitSchemaSize(schema: JsonRecord) { + if (schemaSize(schema) <= MAX_SCHEMA_SIZE) return schema + const compact = stripAnnotations(schema) + if (schemaSize(compact) <= MAX_SCHEMA_SIZE) return compact + return { type: "object", properties: {} } +} + +function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): number { + const properties = isPlainObject(schema.properties) + ? Math.max( + 0, + ...Object.values(schema.properties).map((item) => + isPlainObject(item) ? 1 + schemaDepth(item, root, refs) : 0, + ), + ) + : 0 + const items = isPlainObject(schema.items) ? schemaDepth(schema.items, root, refs) : 0 + const additionalProperties = isPlainObject(schema.additionalProperties) + ? schemaDepth(schema.additionalProperties, root, refs) + : 0 + const anyOf = Array.isArray(schema.anyOf) + ? Math.max(0, ...schema.anyOf.map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0))) + : 0 + const definitions = isPlainObject(schema.$defs) + ? Math.max( + 0, + ...Object.values(schema.$defs).map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0)), + ) + : 0 + const ref = (() => { + if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0 + const target = resolveOutputRef(schema.$ref, root) + if (!target) return 0 + const next = new Set(refs) + next.add(schema.$ref) + return schemaDepth(target, root, next) + })() + return Math.max(properties, items, additionalProperties, anyOf, definitions, ref) +} + +function stripAnnotations(value: JsonRecord): JsonRecord { + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + if (["description", "title", "default"].includes(key)) return [] + if ((key === "properties" || key === "$defs") && isPlainObject(item)) { + return [ + [ + key, + Object.fromEntries( + Object.entries(item).map(([name, schema]) => [ + name, + isPlainObject(schema) ? stripAnnotations(schema) : schema, + ]), + ), + ], + ] + } + if (Array.isArray(item)) { + return [[key, item.map((entry) => (isPlainObject(entry) ? stripAnnotations(entry) : entry))]] + } + return [[key, isPlainObject(item) ? stripAnnotations(item) : item]] + }), + ) +} + +function schemaSize(schema: JsonRecord) { + const json = JSON.stringify(schema).replace(/[<>&\u2028\u2029]/g, (char) => { + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}` + }) + return new TextEncoder().encode(json).byteLength +} + function mergeObjectUnion(branches: JsonRecord[]) { const propertyNames = new Set( branches.flatMap((branch) => (isPlainObject(branch.properties) ? Object.keys(branch.properties) : [])), @@ -225,12 +468,12 @@ function mergeObjectUnion(branches: JsonRecord[]) { } } -function lowerAllOf(source: JsonRecord, root: JsonRecord) { +function lowerAllOf(source: JsonRecord, context: Context) { 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 target = ref ? resolveRef(ref, context) : undefined const next = isPlainObject(target) ? { ...target, ...branch, $ref: undefined } : branch return intersect(result, next) ?? merge(result, next) }, base) @@ -333,38 +576,63 @@ function rewriteRef(value: unknown) { if (ref === "#" || ref.startsWith("#/$defs/")) return ref } -function refExists(ref: string, root: JsonRecord) { +function refExists(ref: string, context: Context) { if (ref === "#") return true - return isPlainObject(resolveRef(ref, root)) + return ref !== "#/$defs/" && isPlainObject(resolveRef(ref, context)) } -function refIsObject(ref: string, root: JsonRecord) { - const target = resolveRef(ref, root) +function refIsObject(ref: string, context: Context) { + const target = resolveRef(ref, context) if (!isPlainObject(target)) return false - const lowered = lowerAllOf(target, root) + const lowered = lowerAllOf(target, context) return lowered.type === "object" || isPlainObject(lowered.properties) } -function resolveRef(ref: string, root: JsonRecord): unknown { - if (ref === "#") return root +function resolveRef(ref: string, context: Context): unknown { + if (ref === "#") return context.root return ref .slice("#/$defs/".length) .split("/") .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) .reduce((current, part, index) => { - if (index === 0) return definitions(root)[part] + if (index === 0) return context.definitions[part] if (!isPlainObject(current)) return return current[part] }, undefined) } -function definitions(root: JsonRecord) { +function collectDefinitions(root: JsonRecord) { return { ...(isPlainObject(root.definitions) ? root.definitions : {}), ...(isPlainObject(root.$defs) ? root.$defs : {}), } } +function referencedDefinitions(root: JsonRecord, definitions: JsonRecord) { + const names = new Set() + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (!isPlainObject(value)) return + + const ref = rewriteRef(value.$ref) + if (ref?.startsWith("#/$defs/")) { + const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") + if (name && !names.has(name) && isPlainObject(definitions[name])) { + names.add(name) + visit(definitions[name]) + } + } + Object.entries(value).forEach(([key, item]) => { + if (key !== "$defs" && key !== "definitions") visit(item) + }) + } + visit(root) + return Object.fromEntries([...names].map((name) => [name, definitions[name]])) +} + function schemaTypes(value: unknown) { if (typeof value === "string" && TYPES.has(value)) return [value] if (!Array.isArray(value)) return [] diff --git a/packages/opencode/test/provider/mfjs.test.ts b/packages/opencode/test/provider/mfjs.test.ts index bee3a9f4d2..e30af14d27 100644 --- a/packages/opencode/test/provider/mfjs.test.ts +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" import { MFJS } from "@/provider/mfjs" +function asObject(value: unknown) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record + throw new Error("expected object") +} + describe("MFJS.sanitize", () => { test("removes siblings from references while preserving definitions", () => { expect( @@ -260,6 +265,21 @@ describe("MFJS.sanitize", () => { }) }) + test("omits unreferenced definitions", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { type: "string" } }, + $defs: { + Unused: { type: "object", properties: { ignored: { type: "string" } } }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: { type: "string" } }, + }) + }) + test("drops references to missing definitions", () => { expect( MFJS.sanitize({ @@ -294,6 +314,186 @@ describe("MFJS.sanitize", () => { }) }) + test("preserves unconstrained child schemas", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + empty: {}, + truthy: true, + falsy: false, + }, + }), + ).toEqual({ + type: "object", + properties: { + empty: {}, + truthy: {}, + falsy: {}, + }, + }) + }) + + test("removes empty property names and references to filtered definitions", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + "": { type: "string" }, + value: { $ref: "#/$defs/Bad~1Name" }, + }, + required: ["", "value"], + $defs: { + "Bad/Name": { type: "object" }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: {} }, + required: ["value"], + }) + }) + + test("makes required recursive roots terminable", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + required: ["node"], + $defs: { + Node: { + type: "object", + properties: { next: { $ref: "#/$defs/Node" } }, + required: ["next"], + }, + }, + }), + ).toEqual({ + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { next: { $ref: "#/$defs/Node" } }, + required: ["next"], + }, + }, + }) + }) + + test("preserves nullable reference semantics", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + value: { $ref: "#/$defs/Value", nullable: true }, + }, + $defs: { + Value: { type: "object", properties: { label: { type: "string" } } }, + }, + }), + ).toEqual({ + type: "object", + properties: { + value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] }, + }, + $defs: { + Value: { type: "object", properties: { label: { type: "string" } } }, + }, + }) + }) + + test("merges referenced object branches at the root", () => { + expect( + MFJS.sanitize({ + anyOf: [{ $ref: "#/$defs/Left" }, { $ref: "#/$defs/Right" }], + $defs: { + Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, + Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + left: { type: "string" }, + right: { type: "number" }, + }, + $defs: { + Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, + Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, + }, + }) + }) + + test("combines inclusive and exclusive integer bounds", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + value: { + type: "integer", + minimum: 0, + exclusiveMinimum: 5, + maximum: 10, + exclusiveMaximum: 8, + }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: { type: "integer", minimum: 6, maximum: 7 } }, + }) + }) + + test("enforces Walle property and size limits", () => { + const properties = Object.fromEntries( + Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), + ) + const limited = asObject(MFJS.sanitize({ type: "object", properties })) + + expect(Object.keys(asObject(limited.properties))).toHaveLength(3000) + expect( + MFJS.sanitize({ + type: "object", + description: "<".repeat(20_000), + properties: { + description: { type: "string" }, + default: { type: "string" }, + }, + }), + ).toEqual({ + type: "object", + properties: { + description: { type: "string" }, + default: { type: "string" }, + }, + }) + }) + + test("enforces Walle depth limits", () => { + const deep = Array.from({ length: 35 }).reduce>( + (schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }), + { type: "string" }, + ) + + expect(JSON.stringify(MFJS.sanitize(deep)).match(/properties/g)?.length).toBe(30) + }) + + test("enforces depth limits across references", () => { + const definition = Array.from({ length: 30 }).reduce>( + (schema) => ({ type: "object", properties: { next: schema } }), + { type: "string" }, + ) + + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { Value: definition }, + }), + ).toEqual({ type: "object", properties: {} }) + }) + test("is idempotent", () => { const once = MFJS.sanitize({ type: "object", From 4d6f3f002c9d2cefec99dc6634adc84300df3907 Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 19:14:52 +0200 Subject: [PATCH 3/6] chore(opencode): add tool schema compatibility matrix --- .../tool-schema-compatibility-matrix.ts | 285 ++++++ packages/opencode/src/provider/mfjs.ts | 845 ++++++------------ packages/opencode/test/provider/mfjs.test.ts | 535 ++++------- .../opencode/test/provider/transform.test.ts | 2 +- 4 files changed, 714 insertions(+), 953 deletions(-) create mode 100644 packages/opencode/script/tool-schema-compatibility-matrix.ts diff --git a/packages/opencode/script/tool-schema-compatibility-matrix.ts b/packages/opencode/script/tool-schema-compatibility-matrix.ts new file mode 100644 index 0000000000..72a490880b --- /dev/null +++ b/packages/opencode/script/tool-schema-compatibility-matrix.ts @@ -0,0 +1,285 @@ +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { JSONSchema7 } from "@ai-sdk/provider" +import { Effect } from "effect" +import { jsonSchema, streamText, tool, type LanguageModel } from "ai" +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { Provider } from "@/provider/provider" +import { InstanceStore } from "@/project/instance-store" +import { MFJS } from "@/provider/mfjs" + +// Usage: +// bun run script/tool-schema-compatibility-matrix.ts --models=model-a,model-b +// [--provider=opencode-go] [--projection=none|mfjs] +// [--cases=tuple items,...] [--concurrency=9] [--timeout=30000] + +type JsonRecord = Record +type Case = { + name: string + schema: JsonRecord +} +type Result = { + case: string + model: string + status: "accepted" | "rejected" | "rate-limited" | "error" + code?: number + error?: string +} + +const projection = option("projection") ?? "none" +if (projection !== "none" && projection !== "mfjs") throw new Error(`Unsupported projection: ${projection}`) +const concurrency = Number(option("concurrency") ?? 9) +const timeout = Number(option("timeout") ?? 30_000) +const providerID = option("provider") ?? "opencode-go" +const models = option("models")?.split(",").filter(Boolean) ?? [] +if (models.length === 0) throw new Error("--models must contain at least one model ID") +const selectedCases = new Set(option("cases")?.split(",").filter(Boolean) ?? []) + +const matrix: Case[] = [ + property("enum/type mismatch", { type: "object", enum: ["move", "copy"] }), + property("untyped enum", { enum: ["move", "copy"] }), + property("mixed untyped enum", { enum: ["move", 1, null, true] }), + property("const", { const: "move" }), + property("tuple items", { type: "array", items: [{ type: "string" }, { type: "number" }] }), + property("prefix items", { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }), + property("typed anyOf", { + type: "string", + enum: ["move"], + anyOf: [{ type: "string" }, { type: "null" }], + }), + property("anyOf count limit", { + anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })), + }), + property("oneOf", { oneOf: [{ type: "string" }, { type: "integer" }] }), + property("allOf", { + allOf: [ + { type: "object", properties: { left: { type: "string" } } }, + { type: "object", properties: { right: { type: "number" } } }, + ], + }), + property("not", { type: "string", not: { enum: ["blocked"] } }), + property("if/then/else", { + type: "object", + properties: { mode: { type: "string" }, count: { type: "integer" } }, + if: { properties: { mode: { enum: ["many"] } } }, + then: { required: ["count"] }, + else: { properties: { count: { maximum: 1 } } }, + }), + property("contains", { type: "array", contains: { type: "string" } }), + objectCase("patternProperties", { + type: "object", + patternProperties: { "^extra_": { type: "number" } }, + additionalProperties: false, + }), + objectCase("dependentSchemas", { + type: "object", + properties: { key: { type: "string" }, value: { type: "string" } }, + dependentSchemas: { key: { required: ["value"] } }, + }), + objectCase("propertyNames", { + type: "object", + propertyNames: { pattern: "^[a-z]+$" }, + }), + property("uniqueItems", { type: "array", items: { type: "string" }, uniqueItems: true }), + property("boolean true schema", true), + property("boolean false schema", false), + objectCase("empty property name", { + type: "object", + properties: { "": { type: "string" } }, + required: [""], + }), + objectCase("dangling required", { + type: "object", + properties: {}, + required: ["missing"], + }), + objectCase("external ref", { + type: "object", + properties: { value: { $ref: "https://example.com/schema.json" } }, + }), + objectCase("chained ref", { + type: "object", + properties: { value: { $ref: "#/$defs/A" } }, + $defs: { A: { $ref: "#/$defs/B" }, B: { type: "string" } }, + }), + objectCase("recursive ref", { + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { value: { type: "string" }, next: { anyOf: [{ $ref: "#/$defs/Node" }, { type: "null" }] } }, + required: ["value"], + }, + }, + }), + objectCase("reference depth limit", { + type: "object", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { Value: nested(30) }, + }), + objectCase("schema size limit", { + type: "object", + description: "x".repeat(120_001), + properties: {}, + }), + objectCase("schema depth limit", nested(35)), + objectCase("property count limit", { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), + ), + }), + property("enum count limit", { + type: "string", + enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`), + }), +] +const cases = selectedCases.size === 0 ? matrix : matrix.filter((item) => selectedCases.has(item.name)) + +const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => + store.load({ directory: process.cwd() }).pipe(Effect.map((ctx) => ({ store, ctx }))), + ), +) + +try { + const languages = await AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* Effect.forEach( + models, + Effect.fnUntraced(function* (model) { + const info = yield* provider.getModel(ProviderV2.ID.make(providerID), ModelV2.ID.make(model)) + return [model, yield* provider.getLanguage(info)] as const + }), + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provideService(InstanceRef, ctx)), + ) + const jobs = languages.flatMap(([model, language]) => cases.map((item) => () => run(language, model, item))) + const results = await parallel(jobs, concurrency) + print(results) + if (projection !== "none" && results.some((result) => result.status !== "accepted")) process.exitCode = 1 +} finally { + await AppRuntime.runPromise(store.dispose(ctx)) +} +process.exit(process.exitCode ?? 0) + +async function run(language: LanguageModel, model: string, item: Case): Promise { + const schema = projection === "mfjs" ? MFJS.sanitize(item.schema) : item.schema + let providerError: unknown + try { + const response = streamText({ + model: language, + prompt: "Reply with exactly OK without calling tools.", + maxOutputTokens: 16, + abortSignal: AbortSignal.timeout(timeout), + onError(event) { + providerError = event.error + }, + tools: { + probe: tool({ + description: `Tool schema probe: ${item.name}`, + inputSchema: jsonSchema(schema as JSONSchema7), + execute: async () => "ok", + }), + }, + }) + await response.text + if (providerError) throw providerError + console.error(`accepted: ${model} / ${item.name}`) + return { case: item.name, model, status: "accepted" } + } catch (error) { + const failure = providerError ?? error + const code = statusCode(failure) + const message = failure instanceof Error ? failure.message : String(failure) + const status = code === 429 || /rate limit/i.test(message) ? "rate-limited" : code === 400 ? "rejected" : "error" + console.error(`${status}: ${model} / ${item.name}`) + return { + case: item.name, + model, + status, + code, + error: message, + } + } +} + +async function parallel(jobs: Array<() => Promise>, limit: number) { + const output = new Array(jobs.length) + let next = 0 + await Promise.all( + Array.from({ length: Math.min(limit, jobs.length) }, async () => { + while (true) { + const index = next++ + const job = jobs[index] + if (!job) return + output[index] = await job() + } + }), + ) + return output +} + +function print(results: Result[]) { + const byCase = new Map>() + results.forEach((result) => { + const row = byCase.get(result.case) ?? new Map() + row.set(result.model, result) + byCase.set(result.case, row) + }) + console.log(`Projection: ${projection}`) + console.log(`Provider: ${providerID}`) + console.log(`| Case | ${models.join(" | ")} |`) + console.log(`| --- | ${models.map(() => "---").join(" | ")} |`) + cases.forEach((item) => { + const row = byCase.get(item.name) + const values = models.map((model) => { + const result = row?.get(model) + if (!result) return "missing" + return result.status === "accepted" ? "accepted" : `${result.status}${result.code ? ` (${result.code})` : ""}` + }) + console.log(`| ${item.name} | ${values.join(" | ")} |`) + }) + const rejected = results.filter((result) => result.status !== "accepted") + if (rejected.length > 0) { + console.log("\nRejected details:") + rejected.forEach((result) => console.log(`- ${result.model} / ${result.case}: ${result.error}`)) + } +} + +function property(name: string, schema: unknown): Case { + return objectCase(name, { type: "object", properties: { value: schema }, required: ["value"] }) +} + +function objectCase(name: string, schema: JsonRecord): Case { + return { name, schema } +} + +function nested(depth: number): JsonRecord { + return Array.from({ length: depth }).reduce( + (schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }), + { type: "string" }, + ) +} + +function option(name: string) { + const prefix = `--${name}=` + return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) +} + +function statusCode(error: unknown, seen = new Set()): number | undefined { + if (!isRecord(error) || seen.has(error)) return + seen.add(error) + if (typeof error.statusCode === "number") return error.statusCode + for (const value of Object.values(error)) { + const nested = statusCode(value, seen) + if (nested !== undefined) return nested + } +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index 8fb4e0ebf6..158d27f195 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -2,7 +2,28 @@ export * as MFJS from "./mfjs" import type { JSONSchema7 } from "@ai-sdk/provider" -// MFJS specification and reference validator: https://github.com/MoonshotAI/walle +/** + * Kimi tool-schema compatibility projection. + * + * Principles: + * - Preserve schema features accepted by Kimi without rewriting them. + * - Apply model-agnostic adaptations only for reproduced provider rejections. + * - Keep explicit types authoritative; lossy fallbacks may widen but never narrow. + * - Leave the original tool schema as the execution-time validation authority. + * + * Adapted families include enum/type conflicts, untyped enums, tuple `items`, + * typed `anyOf`, boolean schemas, dangling `required`, references, and observed + * enum/union/property/size/depth limits. Accepted keywords such as `const`, + * `oneOf`, `allOf`, conditionals, `prefixItems`, and other constraints pass + * through recursively. + * + * `script/kimi-tool-schema-matrix.ts` compares raw and projected schemas across + * Kimi 3, Kimi 2.7 Code, and Kimi 2.6. Keep this projection evidence-driven as + * provider behavior evolves. + * + * MFJS specification and reference implementation: + * https://github.com/MoonshotAI/walle + */ type JsonRecord = Record type Context = { @@ -10,416 +31,200 @@ type Context = { definitions: JsonRecord properties: number } -type Position = { - depth: number - definition: boolean - root: boolean -} const TYPES = new Set(["string", "number", "boolean", "integer", "object", "array", "null"]) -const RESERVED_PROPERTIES = new Set(["$defs", "$ref", "anyOf", "required", "additionalProperties"]) +const SCHEMA_MAPS = new Set(["patternProperties", "dependentSchemas"]) +const SCHEMA_NODES = new Set([ + "additionalProperties", + "not", + "if", + "then", + "else", + "contains", + "propertyNames", + "unevaluatedProperties", +]) +const SCHEMA_LISTS = new Set(["oneOf", "allOf", "prefixItems"]) const MAX_ANY_OF = 500 -const MAX_ENUM = 1000 const MAX_DEPTH = 30 +const MAX_ENUM = 1000 const MAX_PROPERTIES = 3000 const MAX_SCHEMA_SIZE = 120_000 -// Moonshot accepts MFJS, a strict and intentionally smaller JSON Schema dialect. -// Lower broader schemas here so one incompatible tool cannot reject the whole request. +/** + * Projects tool schemas only where Kimi rejects otherwise valid requests. + * Accepted keywords are preserved, explicit types remain authoritative, and + * lossy fallbacks only widen what the model may emit. + */ export function sanitize(value: unknown): JSONSchema7 { - const root = isPlainObject(value) ? value : {} - const definitions = collectDefinitions(root) - const context = { root, definitions: referencedDefinitions(root, definitions), properties: 0 } - const lowered = sanitizeNode(root, context, { depth: 0, definition: false, root: true }) - const rooted = forceObjectRoot(lowered, context) - const referenced = pruneInvalidRefs(rooted, rooted) - const terminating = ensureTermination(forceObjectRoot(referenced, context)) - const depthBounded = - schemaDepth(terminating, terminating, new Set()) <= MAX_DEPTH ? terminating : emptyRoot(terminating) - return fitSchemaSize(forceObjectRoot(depthBounded, context)) as JSONSchema7 -} - -function forceObjectRoot(result: JsonRecord, context: Context) { - if (result.type === "object") return result - if (typeof result.$ref === "string" && refIsObject(result.$ref, context)) return result - if (Array.isArray(result.anyOf)) { - const branches = result.anyOf.filter(isPlainObject).map((branch) => { - if (branch.type === "object" || typeof branch.$ref !== "string" || !refIsObject(branch.$ref, context)) { - return branch - } - const target = resolveRef(branch.$ref, context) - return isPlainObject(target) - ? sanitizeNode(target, context, { depth: 0, definition: false, root: false }) - : branch - }) - if (branches.length > 0 && branches.every((branch) => branch.type === "object")) { - return withMetadata(mergeObjectUnion(branches), result) - } - } - return withMetadata({ type: "object", properties: {} }, result) -} - -function sanitizeNode(value: unknown, context: Context, position: Position): JsonRecord { - if (position.depth >= MAX_DEPTH) return {} - if (value === true || value === false) return position.root ? { type: "object", properties: {} } : {} - if (!isPlainObject(value)) return position.root ? { type: "object", properties: {} } : {} - if (Object.keys(value).length === 0) return position.root ? { type: "object", properties: {} } : {} - - const source = lowerAllOf(value, context) - const ref = rewriteRef(source.$ref) - if (ref && refExists(ref, context)) { - const siblings = Object.fromEntries( - Object.entries(source).filter(([key]) => !["$ref", "description", "title", "default"].includes(key)), - ) - if (Object.keys(siblings).length === 0) return withRootMetadata({ $ref: ref }, source, context, position) - if (Object.keys(siblings).length === 1 && siblings.nullable === true) { - return withRootMetadata({ anyOf: [{ $ref: ref }, { type: "null" }] }, source, context, position) - } - const target = resolveRef(ref, context) - if (isPlainObject(target)) { - return sanitizeNode({ ...target, ...siblings }, context, position) - } - } - - 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, context, child(position))) - .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, context, position) - return withRootMetadata(result, source, context, position) - } - - 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 }, context, child(position)), - ) - const result = branches.length === 1 ? (branches[0] ?? {}) : { anyOf: branches } - return withRootMetadata(result, source, context, position) - } - - 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 && !position.definition) result.default = source.default - - if (type === "object") { - if (isPlainObject(source.properties)) { - const remaining = Math.max(0, MAX_PROPERTIES - context.properties) - const entries = Object.entries(source.properties) - .filter( - ([name]) => - name.length > 0 && !RESERVED_PROPERTIES.has(name) && (!position.definition || !name.includes("/")), - ) - .slice(0, remaining) - context.properties += entries.length - result.properties = Object.fromEntries( - entries.map(([name, schema]) => [name, sanitizeNode(schema, context, child(position))]), - ) - } - 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, context, child(position)) - } - } - - 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, context, child(position))).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 minimums = [ - ...(typeof source.minimum === "number" ? [source.minimum] : []), - ...(type === "integer" && typeof source.exclusiveMinimum === "number" - ? [Math.floor(source.exclusiveMinimum) + 1] - : []), - ] - const maximums = [ - ...(typeof source.maximum === "number" ? [source.maximum] : []), - ...(type === "integer" && typeof source.exclusiveMaximum === "number" - ? [Math.ceil(source.exclusiveMaximum) - 1] - : []), - ] - const minimum = minimums.length > 0 ? Math.max(...minimums) : undefined - const maximum = maximums.length > 0 ? Math.min(...maximums) : 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, context, position) -} - -function withRootMetadata(result: JsonRecord, source: JsonRecord, context: Context, position: Position) { - if (!position.root) return result - if (typeof source.$id === "string") result.$id = source.$id - - const sanitized = Object.fromEntries( + const root = isRecord(value) ? value : {} + const context = { root, definitions: definitions(root), properties: 0 } + const projected = project(root, context, 0) + const defs = Object.fromEntries( Object.entries(context.definitions) - .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isPlainObject(schema)) - .map(([name, schema]) => [name, sanitizeNode(schema, context, { depth: 0, definition: true, root: false })]), + .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isRecord(schema)) + .map(([name, schema]) => [name, project(schema, context, 0)]), ) - if (Object.keys(sanitized).length > 0) result.$defs = sanitized - return result + if (Object.keys(defs).length > 0) projected.$defs = defs + const bounded = + schemaDepth(projected, projected, new Set()) > MAX_DEPTH ? { type: "object", properties: {} } : projected + return fitSize(bounded) as JSONSchema7 } -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 project(value: unknown, context: Context, depth: number): JsonRecord { + if (depth >= MAX_DEPTH) return {} + if (value === true || value === false || !isRecord(value) || Object.keys(value).length === 0) return {} -function child(position: Position): Position { - return { depth: position.depth + 1, definition: position.definition, root: false } -} - -function pruneInvalidRefs(schema: JsonRecord, root: JsonRecord): JsonRecord { - if (typeof schema.$ref === "string" && !resolveOutputRef(schema.$ref, root)) return {} - - const result = { ...schema } - if (isPlainObject(schema.properties)) { - result.properties = Object.fromEntries( - Object.entries(schema.properties).map(([name, property]) => [ - name, - isPlainObject(property) ? pruneInvalidRefs(property, root) : {}, - ]), - ) - } - if (isPlainObject(schema.items)) result.items = pruneInvalidRefs(schema.items, root) - if (isPlainObject(schema.additionalProperties)) { - result.additionalProperties = pruneInvalidRefs(schema.additionalProperties, root) - } - if (Array.isArray(schema.anyOf)) { - const branches = schema.anyOf.filter(isPlainObject).map((branch) => pruneInvalidRefs(branch, root)) - if (branches.length === 1) return withMetadata(branches[0] ?? {}, result) - result.anyOf = branches - } - if (isPlainObject(schema.$defs)) { - result.$defs = Object.fromEntries( - Object.entries(schema.$defs).map(([name, definition]) => [ - name, - isPlainObject(definition) ? pruneInvalidRefs(definition, root) : {}, - ]), - ) - } - return result -} - -function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { - if (ref === "#") return root - if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return - const parts = ref - .slice("#/$defs/".length) - .split("/") - .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) - return parts.reduce((current, part, index) => { - const value = index === 0 ? (isPlainObject(root.$defs) ? root.$defs[part] : undefined) : current?.[part] - return isPlainObject(value) ? value : undefined - }, undefined) -} - -function ensureTermination(schema: JsonRecord) { - if (terminates(schema, schema, new Set())) return schema - if (schema.type === "object") { - const { required: _, ...result } = schema - return result - } - if (schema.type === "array") { - const { items: _, ...result } = schema - return result - } - return emptyRoot(schema) -} - -function emptyRoot(source: JsonRecord) { - return { - type: "object", - properties: {}, - ...(typeof source.$id === "string" ? { $id: source.$id } : {}), - } -} - -function terminates(schema: JsonRecord, root: JsonRecord, refs: Set): boolean { - const types = schemaTypes(schema.type) - if (types.some((type) => type !== "object" && type !== "array")) return true - - if (types.includes("array")) { - if (!isPlainObject(schema.items) || Object.keys(schema.items).length === 0) return true - if (terminates(schema.items, root, new Set(refs))) return true + const ref = canonicalRef(value.$ref, context) + if (ref) { + if (value.nullable === true) return { anyOf: [{ $ref: ref }, { type: "null" }] } + return { $ref: ref } } - if (types.includes("object")) { - const required = Array.isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === "string") - : [] - const properties = isPlainObject(schema.properties) ? schema.properties : undefined - if (required.length === 0 || !properties || Object.keys(properties).length === 0) return true - if ( - required.some((name) => { - const property = properties[name] - return isPlainObject(property) && terminates(property, root, new Set(refs)) - }) - ) { - return true + if (Array.isArray(value.anyOf) && schemaTypes(value.type).length > 0) { + return projectTypedAnyOf(value, context, depth) + } + + const result: JsonRecord = {} + let truncatedProperties = false + for (const [key, item] of Object.entries(value)) { + if (key === "$defs" || key === "definitions") continue + if (key === "$ref") continue + if (key === "items" && Array.isArray(item)) continue + if (key === "anyOf" && Array.isArray(item)) { + if (item.length <= MAX_ANY_OF) result.anyOf = item.map((branch) => project(branch, context, depth + 1)) + continue } + if (SCHEMA_LISTS.has(key) && Array.isArray(item)) { + result[key] = item.map((schema) => project(schema, context, depth + 1)) + continue + } + if (key === "properties" && isRecord(item)) { + const remaining = Math.max(0, MAX_PROPERTIES - context.properties) + const entries = Object.entries(item).slice(0, remaining) + context.properties += entries.length + truncatedProperties = entries.length !== Object.keys(item).length + result.properties = Object.fromEntries( + entries.map(([name, schema]) => [name, project(schema, context, depth + 1)]), + ) + continue + } + if (SCHEMA_MAPS.has(key) && isRecord(item)) { + result[key] = Object.fromEntries( + Object.entries(item).map(([name, schema]) => [name, project(schema, context, depth + 1)]), + ) + continue + } + if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(item)) { + result[key] = project(item, context, depth + 1) + continue + } + result[key] = item } - - if (Array.isArray(schema.anyOf)) { - if (schema.anyOf.some((branch) => isPlainObject(branch) && terminates(branch, root, new Set(refs)))) return true - } - - if (typeof schema.$ref === "string") { - if (refs.has(schema.$ref)) return false - const target = resolveOutputRef(schema.$ref, root) - if (!target) return false - const next = new Set(refs) - next.add(schema.$ref) - return terminates(target, root, next) - } - return Object.keys(schema).length === 0 + projectRequired(result, context) + if (truncatedProperties) delete result.additionalProperties + return projectEnum(result) } -function fitSchemaSize(schema: JsonRecord) { +function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number) { + const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"]) + const parentTypes = schemaTypes(source.type) + const parentEnum = enumValues("const" in source ? [source.const] : source.enum) + const variants = Array.isArray(source.anyOf) ? source.anyOf : [] + if (variants.length > MAX_ANY_OF) return project(omit(source, ["anyOf"]), context, depth) + const branches = variants.filter(isRecord).flatMap((branch) => { + const types = intersectTypes(parentTypes, schemaTypes(branch.type)) + if (types.length === 0) return [] + const branchEnum = enumValues("const" in branch ? [branch.const] : branch.enum) + const values = intersectEnums(parentEnum, branchEnum).filter((value) => + types.some((type) => matchesType(value, type)), + ) + if ((parentEnum.length > 0 || branchEnum.length > 0) && values.length === 0) return [] + const merged = omit({ ...base, ...branch }, ["type", "enum", "const"]) + return [ + project( + { + ...merged, + type: types.length === 1 ? types[0] : types, + ...(values.length > 0 ? { enum: values } : {}), + }, + context, + depth + 1, + ), + ] + }) + return collapse(branches) +} + +function projectEnum(source: JsonRecord) { + const result = { ...source } + const values = enumValues(result.enum) + if (values.length === 0) { + delete result.enum + return result + } + + const types = schemaTypes(result.type) + if (types.length > 0) { + const compatible = values.filter((value) => types.some((type) => matchesType(value, type))) + if (compatible.length > 0) result.enum = compatible + else delete result.enum + return result + } + + const groups = groupEnum(values) + if (groups.length === 1) { + result.type = groups[0]?.type + result.enum = groups[0]?.values + return result + } + const base = omit(result, ["enum", "type"]) + return { + anyOf: groups.map((group) => ({ ...base, type: group.type, enum: group.values })), + } +} + +function projectRequired(schema: JsonRecord, context: Context) { + if (schema.type !== "object" || !Array.isArray(schema.required)) return + const properties = isRecord(schema.properties) ? { ...schema.properties } : {} + const required = [...new Set(schema.required.filter((item): item is string => typeof item === "string"))].filter( + (name) => { + if (Object.hasOwn(properties, name)) return true + if (context.properties >= MAX_PROPERTIES) return false + context.properties++ + properties[name] = {} + return true + }, + ) + schema.properties = properties + schema.required = required +} + +function fitSize(schema: JsonRecord) { if (schemaSize(schema) <= MAX_SCHEMA_SIZE) return schema const compact = stripAnnotations(schema) if (schemaSize(compact) <= MAX_SCHEMA_SIZE) return compact return { type: "object", properties: {} } } -function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): number { - const properties = isPlainObject(schema.properties) - ? Math.max( - 0, - ...Object.values(schema.properties).map((item) => - isPlainObject(item) ? 1 + schemaDepth(item, root, refs) : 0, - ), - ) - : 0 - const items = isPlainObject(schema.items) ? schemaDepth(schema.items, root, refs) : 0 - const additionalProperties = isPlainObject(schema.additionalProperties) - ? schemaDepth(schema.additionalProperties, root, refs) - : 0 - const anyOf = Array.isArray(schema.anyOf) - ? Math.max(0, ...schema.anyOf.map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0))) - : 0 - const definitions = isPlainObject(schema.$defs) - ? Math.max( - 0, - ...Object.values(schema.$defs).map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0)), - ) - : 0 - const ref = (() => { - if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0 - const target = resolveOutputRef(schema.$ref, root) - if (!target) return 0 - const next = new Set(refs) - next.add(schema.$ref) - return schemaDepth(target, root, next) - })() - return Math.max(properties, items, additionalProperties, anyOf, definitions, ref) -} - -function stripAnnotations(value: JsonRecord): JsonRecord { +function stripAnnotations(schema: JsonRecord): JsonRecord { return Object.fromEntries( - Object.entries(value).flatMap(([key, item]) => { - if (["description", "title", "default"].includes(key)) return [] - if ((key === "properties" || key === "$defs") && isPlainObject(item)) { + Object.entries(schema).flatMap(([key, value]) => { + if (["description", "title", "default", "examples", "$comment"].includes(key)) return [] + if ((key === "properties" || key === "$defs") && isRecord(value)) { return [ [ key, Object.fromEntries( - Object.entries(item).map(([name, schema]) => [ - name, - isPlainObject(schema) ? stripAnnotations(schema) : schema, - ]), + Object.entries(value).map(([name, item]) => [name, isRecord(item) ? stripAnnotations(item) : item]), ), ], ] } - if (Array.isArray(item)) { - return [[key, item.map((entry) => (isPlainObject(entry) ? stripAnnotations(entry) : entry))]] + if (Array.isArray(value)) { + return [[key, value.map((item) => (isRecord(item) ? stripAnnotations(item) : item))]] } - return [[key, isPlainObject(item) ? stripAnnotations(item) : item]] + return [[key, isRecord(value) ? stripAnnotations(value) : value]] }), ) } @@ -431,161 +236,55 @@ function schemaSize(schema: JsonRecord) { return new TextEncoder().encode(json).byteLength } -function mergeObjectUnion(branches: JsonRecord[]) { - const propertyNames = new Set( - branches.flatMap((branch) => (isPlainObject(branch.properties) ? Object.keys(branch.properties) : [])), +function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): number { + const properties = isRecord(schema.properties) + ? Math.max( + 0, + ...Object.values(schema.properties).map((item) => (isRecord(item) ? 1 + schemaDepth(item, root, refs) : 0)), + ) + : 0 + const nodes = [schema.items, schema.additionalProperties].flatMap((item) => + isRecord(item) ? [schemaDepth(item, root, refs)] : [], ) - 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 lists = [schema.anyOf, schema.oneOf, schema.allOf, schema.prefixItems].flatMap((items) => + Array.isArray(items) ? items.flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) : [], ) - const first = Array.isArray(branches[0]?.required) - ? branches[0].required.filter((item): item is string => typeof item === "string") + const definitions = isRecord(schema.$defs) + ? Object.values(schema.$defs).flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) : [] - 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 } : {}), - } + const ref = (() => { + if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0 + const target = resolveOutputRef(schema.$ref, root) + if (!target) return 0 + const next = new Set(refs) + next.add(schema.$ref) + return schemaDepth(target, root, next) + })() + return Math.max(properties, ...nodes, ...lists, ...definitions, ref) } -function lowerAllOf(source: JsonRecord, context: Context) { - 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, context) : undefined - const next = isPlainObject(target) ? { ...target, ...branch, $ref: undefined } : branch - return intersect(result, next) ?? merge(result, next) - }, base) +function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { + if (ref === "#") return root + const defs = isRecord(root.$defs) ? root.$defs : undefined + if (!ref.startsWith("#/$defs/") || !defs) return + return ref + .slice("#/$defs/".length) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((current, part, index) => { + const value = index === 0 ? defs[part] : current?.[part] + return isRecord(value) ? value : undefined + }, undefined) } -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) { +function canonicalRef(value: unknown, context: Context) { if (typeof value !== "string") return const ref = value.replace("#/definitions/", "#/$defs/") - if (ref === "#" || ref.startsWith("#/$defs/")) return ref -} - -function refExists(ref: string, context: Context) { - if (ref === "#") return true - return ref !== "#/$defs/" && isPlainObject(resolveRef(ref, context)) -} - -function refIsObject(ref: string, context: Context) { - const target = resolveRef(ref, context) - if (!isPlainObject(target)) return false - const lowered = lowerAllOf(target, context) - return lowered.type === "object" || isPlainObject(lowered.properties) + if (ref === "#") return ref + if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return + const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") + if (!name || name.includes("/")) return + return isRecord(resolveRef(ref, context)) ? ref : undefined } function resolveRef(ref: string, context: Context): unknown { @@ -596,54 +295,46 @@ function resolveRef(ref: string, context: Context): unknown { .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) .reduce((current, part, index) => { if (index === 0) return context.definitions[part] - if (!isPlainObject(current)) return - return current[part] + return isRecord(current) ? current[part] : undefined }, undefined) } -function collectDefinitions(root: JsonRecord) { +function definitions(root: JsonRecord) { return { - ...(isPlainObject(root.definitions) ? root.definitions : {}), - ...(isPlainObject(root.$defs) ? root.$defs : {}), + ...(isRecord(root.definitions) ? root.definitions : {}), + ...(isRecord(root.$defs) ? root.$defs : {}), } } -function referencedDefinitions(root: JsonRecord, definitions: JsonRecord) { - const names = new Set() - const visit = (value: unknown) => { - if (Array.isArray(value)) { - value.forEach(visit) - return - } - if (!isPlainObject(value)) return - - const ref = rewriteRef(value.$ref) - if (ref?.startsWith("#/$defs/")) { - const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") - if (name && !names.has(name) && isPlainObject(definitions[name])) { - names.add(name) - visit(definitions[name]) - } - } - Object.entries(value).forEach(([key, item]) => { - if (key !== "$defs" && key !== "definitions") visit(item) - }) - } - visit(root) - return Object.fromEntries([...names].map((name) => [name, definitions[name]])) -} - 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 [] +function intersectTypes(parent: string[], child: string[]) { + if (child.length === 0) return parent return [ - ...new Map(value.filter((item) => valueType(item)).map((item) => [JSON.stringify(item), item])).values(), - ].slice(0, MAX_ENUM) + ...new Set([ + ...parent.filter((type) => child.includes(type)), + ...((parent.includes("number") && child.includes("integer")) || + (parent.includes("integer") && child.includes("number")) + ? ["integer"] + : []), + ]), + ] +} + +function enumValues(value: unknown) { + if (!Array.isArray(value)) return [] + const values = unique(value.filter((item) => valueType(item) !== undefined)) + return values.length > MAX_ENUM ? [] : values +} + +function intersectEnums(parent: unknown[], child: unknown[]) { + if (parent.length === 0) return child + if (child.length === 0) return parent + return parent.filter((value) => child.some((item) => Object.is(value, item))) } function groupEnum(values: unknown[]) { @@ -665,42 +356,28 @@ function valueType(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? "integer" : "number" } -function typeMatches(value: unknown, type: string) { +function matchesType(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 collapse(branches: JsonRecord[]) { + const projected = unique(branches) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] ?? {} + return { anyOf: projected } } -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 unique(values: T[]) { + return [...new Map(values.map((value) => [JSON.stringify(value), value])).values()] } -function isPlainObject(value: unknown): value is JsonRecord { +function omit(source: JsonRecord, keys: string[]) { + const omitted = new Set(keys) + return Object.fromEntries(Object.entries(source).filter(([key]) => !omitted.has(key))) +} + +function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/packages/opencode/test/provider/mfjs.test.ts b/packages/opencode/test/provider/mfjs.test.ts index e30af14d27..5f8f2af5ce 100644 --- a/packages/opencode/test/provider/mfjs.test.ts +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -1,101 +1,141 @@ import { describe, expect, test } from "bun:test" import { MFJS } from "@/provider/mfjs" +function expectProjection(input: unknown, expected: unknown) { + expect(JSON.stringify(MFJS.sanitize(input))).toBe(JSON.stringify(expected)) +} + function asObject(value: unknown) { if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record throw new Error("expected object") } describe("MFJS.sanitize", () => { - test("removes siblings from references while preserving definitions", () => { + test("removes reference siblings and normalizes draft-07 definitions", () => { expect( MFJS.sanitize({ type: "object", properties: { - value: { - $ref: "#/$defs/Value", - description: "Moonshot rejects siblings after expanding a reference.", - }, + value: { $ref: "#/definitions/Value", description: "drop me" }, }, - $defs: { - Value: { type: "object", description: "The referenced description remains here." }, + definitions: { + Value: { type: "object", description: "keep me" }, }, }), ).toEqual({ type: "object", properties: { value: { $ref: "#/$defs/Value" } }, - $defs: { - Value: { type: "object", description: "The referenced description remains here." }, - }, + $defs: { Value: { type: "object", description: "keep me" } }, }) }) - test("repairs enum types that contradict their values", () => { + test("preserves nullable reference semantics", () => { expect( MFJS.sanitize({ type: "object", - properties: { - operation: { type: "object", enum: ["move", "copy"] }, - }, + properties: { value: { $ref: "#/$defs/Value", nullable: true } }, + $defs: { Value: { type: "object" } }, + }), + ).toEqual({ + type: "object", + properties: { value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] } }, + $defs: { Value: { type: "object" } }, + }) + }) + + test("keeps explicit types and removes incompatible enum 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"] }, - }, + properties: { operation: { type: "object" } }, required: ["operation"], }) }) - test("lowers type arrays and nullable enums to anyOf", () => { + test("removes only enum values excluded by an explicit type", () => { expect( MFJS.sanitize({ type: "object", - properties: { - operation: { type: ["string", "null"], enum: ["move", null] }, - }, + properties: { operation: { type: "string", enum: ["move", null] } }, + }), + ).toEqual({ + type: "object", + properties: { operation: { type: "string", enum: ["move"] } }, + }) + }) + + test("infers a type for homogeneous untyped enums", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { operation: { enum: ["move", "copy"] } }, + }), + ).toEqual({ + type: "object", + properties: { operation: { type: "string", enum: ["move", "copy"] } }, + }) + }) + + test("splits mixed untyped enums into homogeneous branches", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { enum: ["move", 1] } }, }), ).toEqual({ type: "object", properties: { - operation: { + value: { anyOf: [ { type: "string", enum: ["move"] }, - { type: "null", enum: [null] }, + { type: "integer", enum: [1] }, ], }, }, }) }) - test("normalizes single-value type arrays", () => { + test("drops tuple items instead of narrowing positional schemas", () => { expect( MFJS.sanitize({ type: "object", - properties: { value: { type: ["null"] } }, + properties: { + values: { + type: "array", + items: [{ type: "string" }, { type: "number" }], + minItems: 2, + }, + }, }), ).toEqual({ type: "object", - properties: { value: { type: "null" } }, + properties: { values: { type: "array", minItems: 2 } }, }) }) - test("preserves scalar types when lowering nullable schemas", () => { - expect( - MFJS.sanitize({ + test("preserves prefixItems accepted by Kimi", () => { + expectProjection( + { type: "object", - properties: { value: { type: "string", nullable: true } }, - }), - ).toEqual({ - type: "object", - properties: { - value: { anyOf: [{ type: "string" }, { type: "null" }] }, + properties: { + values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + }, }, - }) + { + type: "object", + properties: { + values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + }, + }, + ) }) - test("intersects parent constraints into anyOf branches", () => { + test("moves parent types into compatible anyOf branches", () => { expect( MFJS.sanitize({ type: "object", @@ -113,378 +153,141 @@ describe("MFJS.sanitize", () => { }) }) - test("infers missing types and filters dangling required fields", () => { - expect( - MFJS.sanitize({ + test("preserves type arrays and nullable accepted by Kimi", () => { + expectProjection( + { + type: "object", properties: { - mode: { enum: ["fast", "safe"] }, - options: { properties: { retries: { type: "integer" } } }, - values: { items: { type: "number" } }, + typed: { type: ["string", "null"], enum: ["move", null] }, + nullable: { type: "string", nullable: true }, }, - 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"], - }) + { + type: "object", + properties: { + typed: { type: ["string", "null"], enum: ["move", null] }, + nullable: { type: "string", nullable: true }, + }, + }, + ) }) - test("converts oneOf and strips unsupported keywords", () => { + test("preserves oneOf, allOf, and other keywords accepted by Kimi", () => { + const value = { + oneOf: [ + { type: "string", format: "uri" }, + { type: "integer", multipleOf: 2 }, + ], + examples: ["https://example.com"], + } + const all = { + allOf: [ + { type: "object", properties: { left: { type: "string" } } }, + { type: "object", properties: { right: { type: "number" } } }, + ], + } + expectProjection( + { type: "object", properties: { value, all }, unevaluatedProperties: false }, + { type: "object", properties: { value, all }, unevaluatedProperties: false }, + ) + }) + + test("preserves const accepted by Kimi", () => { 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, + properties: { operation: { const: "move" } }, }), ).toEqual({ type: "object", - properties: { - value: { anyOf: [{ type: "string" }, { type: "integer" }] }, - }, + properties: { operation: { const: "move" } }, }) }) - test("widens heterogeneous tuples without narrowing item types", () => { + test("drops unresolved references", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { $ref: "#/definitions/Missing" } }, + }), + ).toEqual({ + type: "object", + properties: { value: {} }, + }) + }) + + test("adds unconstrained schemas for dangling required properties", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: {}, + required: ["missing"], + }), + ).toEqual({ + type: "object", + properties: { missing: {} }, + required: ["missing"], + }) + }) + + test("preserves unconstrained schemas", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { empty: {}, truthy: true, falsy: false }, + }), + ).toEqual({ + type: "object", + properties: { empty: {}, truthy: {}, falsy: {} }, + }) + }) + + test("widens schemas that exceed Kimi limits", () => { + const properties = Object.fromEntries( + Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), + ) + const limited = asObject(MFJS.sanitize({ type: "object", properties })) + expect(Object.keys(asObject(limited.properties))).toHaveLength(3000) + 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("omits unreferenced definitions", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { value: { type: "string" } }, - $defs: { - Unused: { type: "object", properties: { ignored: { type: "string" } } }, + value: { type: "string", enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`) }, }, }), ).toEqual({ type: "object", properties: { value: { type: "string" } }, }) - }) - 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("preserves unconstrained child schemas", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - empty: {}, - truthy: true, - falsy: false, - }, - }), - ).toEqual({ - type: "object", - properties: { - empty: {}, - truthy: {}, - falsy: {}, - }, - }) - }) - - test("removes empty property names and references to filtered definitions", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - "": { type: "string" }, - value: { $ref: "#/$defs/Bad~1Name" }, - }, - required: ["", "value"], - $defs: { - "Bad/Name": { type: "object" }, + value: { anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })) }, }, }), ).toEqual({ type: "object", properties: { value: {} }, - required: ["value"], }) - }) - test("makes required recursive roots terminable", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { node: { $ref: "#/$defs/Node" } }, - required: ["node"], - $defs: { - Node: { - type: "object", - properties: { next: { $ref: "#/$defs/Node" } }, - required: ["next"], - }, - }, - }), - ).toEqual({ - type: "object", - properties: { node: { $ref: "#/$defs/Node" } }, - $defs: { - Node: { - type: "object", - properties: { next: { $ref: "#/$defs/Node" } }, - required: ["next"], - }, - }, - }) - }) - - test("preserves nullable reference semantics", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - value: { $ref: "#/$defs/Value", nullable: true }, - }, - $defs: { - Value: { type: "object", properties: { label: { type: "string" } } }, - }, - }), - ).toEqual({ - type: "object", - properties: { - value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] }, - }, - $defs: { - Value: { type: "object", properties: { label: { type: "string" } } }, - }, - }) - }) - - test("merges referenced object branches at the root", () => { - expect( - MFJS.sanitize({ - anyOf: [{ $ref: "#/$defs/Left" }, { $ref: "#/$defs/Right" }], - $defs: { - Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, - Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, - }, - }), - ).toEqual({ - type: "object", - properties: { - left: { type: "string" }, - right: { type: "number" }, - }, - $defs: { - Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, - Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, - }, - }) - }) - - test("combines inclusive and exclusive integer bounds", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - value: { - type: "integer", - minimum: 0, - exclusiveMinimum: 5, - maximum: 10, - exclusiveMaximum: 8, - }, - }, - }), - ).toEqual({ - type: "object", - properties: { value: { type: "integer", minimum: 6, maximum: 7 } }, - }) - }) - - test("enforces Walle property and size limits", () => { - const properties = Object.fromEntries( - Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), - ) - const limited = asObject(MFJS.sanitize({ type: "object", properties })) - - expect(Object.keys(asObject(limited.properties))).toHaveLength(3000) - expect( - MFJS.sanitize({ - type: "object", - description: "<".repeat(20_000), - properties: { - description: { type: "string" }, - default: { type: "string" }, - }, - }), - ).toEqual({ - type: "object", - properties: { - description: { type: "string" }, - default: { type: "string" }, - }, - }) - }) - - test("enforces Walle depth limits", () => { const deep = Array.from({ length: 35 }).reduce>( (schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }), { type: "string" }, ) - expect(JSON.stringify(MFJS.sanitize(deep)).match(/properties/g)?.length).toBe(30) - }) - test("enforces depth limits across references", () => { + expect(MFJS.sanitize({ type: "object", description: "<".repeat(20_000), properties: {} })).toEqual({ + type: "object", + properties: {}, + }) + const definition = Array.from({ length: 30 }).reduce>( (schema) => ({ type: "object", properties: { next: schema } }), { type: "string" }, ) - expect( MFJS.sanitize({ type: "object", @@ -498,14 +301,10 @@ describe("MFJS.sanitize", () => { const once = MFJS.sanitize({ type: "object", properties: { - operation: { type: ["string", "null"], enum: ["move", null] }, + operation: { type: "object", enum: ["move"] }, values: { type: "array", items: [{ type: "string" }, { type: "number" }] }, }, - definitions: { - Metadata: { type: "object", properties: { label: { type: "string", format: "uri" } } }, - }, }) - expect(MFJS.sanitize(once)).toEqual(once) }) }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c02798455b..dc7e503cd0 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1546,7 +1546,7 @@ describe("ProviderTransform.schema - MFJS selection", () => { ).toEqual({ type: "object", properties: { - operation: { type: "string", enum: ["move", "copy"] }, + operation: { type: "object" }, }, }) }) From 50a29ff19fae7cc0c534725da490ee5da372257d Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 19:15:02 +0200 Subject: [PATCH 4/6] Update mfjs.ts --- packages/opencode/src/provider/mfjs.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index 158d27f195..cc73723ee1 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -17,9 +17,9 @@ import type { JSONSchema7 } from "@ai-sdk/provider" * `oneOf`, `allOf`, conditionals, `prefixItems`, and other constraints pass * through recursively. * - * `script/kimi-tool-schema-matrix.ts` compares raw and projected schemas across - * Kimi 3, Kimi 2.7 Code, and Kimi 2.6. Keep this projection evidence-driven as - * provider behavior evolves. + * `script/tool-schema-compatibility-matrix.ts` compares raw and MFJS-projected + * schemas across configured providers and models. Keep this projection + * evidence-driven as provider behavior evolves. * * MFJS specification and reference implementation: * https://github.com/MoonshotAI/walle From d2a11baa993e8c01c7984aa432c57a9a1aef65be Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 20:04:22 +0200 Subject: [PATCH 5/6] fix(provider): harden mfjs schema projection --- packages/opencode/src/provider/mfjs.ts | 371 +++++++++++++++++++++---- 1 file changed, 311 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index cc73723ee1..e0df83d584 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -9,7 +9,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider" * - Preserve schema features accepted by Kimi without rewriting them. * - Apply model-agnostic adaptations only for reproduced provider rejections. * - Keep explicit types authoritative; lossy fallbacks may widen but never narrow. - * - Leave the original tool schema as the execution-time validation authority. * * Adapted families include enum/type conflicts, untyped enums, tuple `items`, * typed `anyOf`, boolean schemas, dangling `required`, references, and observed @@ -29,8 +28,14 @@ type JsonRecord = Record type Context = { root: JsonRecord definitions: JsonRecord + legacy: Record properties: number } +type Projection = { + schema: JsonRecord + // Unsafe projections cannot stay under non-monotonic applicators without risking narrowing. + unsafe: boolean +} const TYPES = new Set(["string", "number", "boolean", "integer", "object", "array", "null"]) const SCHEMA_MAPS = new Set(["patternProperties", "dependentSchemas"]) @@ -44,11 +49,12 @@ const SCHEMA_NODES = new Set([ "propertyNames", "unevaluatedProperties", ]) -const SCHEMA_LISTS = new Set(["oneOf", "allOf", "prefixItems"]) +const SCHEMA_LISTS = new Set(["allOf", "prefixItems"]) const MAX_ANY_OF = 500 const MAX_DEPTH = 30 const MAX_ENUM = 1000 const MAX_PROPERTIES = 3000 +const MAX_RECURSION = 1000 const MAX_SCHEMA_SIZE = 120_000 /** @@ -58,45 +64,132 @@ const MAX_SCHEMA_SIZE = 120_000 */ export function sanitize(value: unknown): JSONSchema7 { const root = isRecord(value) ? value : {} - const context = { root, definitions: definitions(root), properties: 0 } - const projected = project(root, context, 0) + const sourceDefinitions = definitions(root) + const context = { root, definitions: sourceDefinitions.schemas, legacy: sourceDefinitions.legacy, properties: 0 } + const projected = project(root, context, 0, 0).schema const defs = Object.fromEntries( Object.entries(context.definitions) .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isRecord(schema)) - .map(([name, schema]) => [name, project(schema, context, 0)]), + .map(([name, schema]) => [name, containsSlashKey(schema) ? {} : project(schema, context, 0, 0).schema]), ) if (Object.keys(defs).length > 0) projected.$defs = defs + const resolved = dropDanglingRefs(projected, projected) const bounded = - schemaDepth(projected, projected, new Set()) > MAX_DEPTH ? { type: "object", properties: {} } : projected + (containsRef(resolved) && !terminates(resolved, resolved, new Set())) || + schemaDepth(resolved, resolved, new Set()) > MAX_DEPTH + ? { type: "object", properties: {} } + : resolved return fitSize(bounded) as JSONSchema7 } -function project(value: unknown, context: Context, depth: number): JsonRecord { - if (depth >= MAX_DEPTH) return {} - if (value === true || value === false || !isRecord(value) || Object.keys(value).length === 0) return {} +function project(value: unknown, context: Context, depth: number, recursion: number): Projection { + if (depth >= MAX_DEPTH || recursion >= MAX_RECURSION) return { schema: {}, unsafe: true } + if (!isRecord(value) || Object.keys(value).length === 0) { + return { schema: {}, unsafe: !isRecord(value) } + } const ref = canonicalRef(value.$ref, context) if (ref) { - if (value.nullable === true) return { anyOf: [{ $ref: ref }, { type: "null" }] } - return { $ref: ref } + const schema = value.nullable === true ? { anyOf: [{ $ref: ref }, { type: "null" }] } : { $ref: ref } + return { + schema, + // References are conservative here because their projected targets may widen later. + unsafe: true, + } } - if (Array.isArray(value.anyOf) && schemaTypes(value.type).length > 0) { - return projectTypedAnyOf(value, context, depth) + const declaredTypes = schemaTypes(value.type) + const inferredTypes = + declaredTypes.length > 0 + ? declaredTypes + : groupEnum(enumValues("const" in value ? [value.const] : value.enum)).map((group) => group.type) + if (Array.isArray(value.anyOf) && inferredTypes.length > 0) { + return projectTypedAnyOf( + { ...value, type: inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes }, + context, + depth, + recursion, + ) } const result: JsonRecord = {} + let unsafe = "$ref" in value || "$defs" in value || "definitions" in value let truncatedProperties = false + let widenedContains = false + const child = (item: unknown, nextDepth = depth, nextContext = context) => + project(item, nextContext, nextDepth, recursion + 1) + const keep = (projection: Projection) => { + unsafe ||= projection.unsafe + return projection.schema + } + const condition = (() => { + if (!isRecord(value.if)) return + const nested = { ...context } + const projection = child(value.if, depth, nested) + if (!projection.unsafe) context.properties = nested.properties + return projection + })() for (const [key, item] of Object.entries(value)) { if (key === "$defs" || key === "definitions") continue if (key === "$ref") continue - if (key === "items" && Array.isArray(item)) continue + if (key === "items" && Array.isArray(item)) { + unsafe = true + continue + } + if (key === "items" && (item === true || item === false)) { + result.items = {} + unsafe = true + continue + } if (key === "anyOf" && Array.isArray(item)) { - if (item.length <= MAX_ANY_OF) result.anyOf = item.map((branch) => project(branch, context, depth + 1)) + if (item.length > 0 && item.length <= MAX_ANY_OF) { + result.anyOf = item.map((branch) => keep(child(branch))) + } else { + unsafe = true + } + continue + } + if (key === "oneOf" && Array.isArray(item)) { + const nested = { ...context } + const branches = item.map((branch) => child(branch, depth, nested)) + const risky = branches.some((branch) => branch.unsafe) + const useBranches = + !risky || (!Array.isArray(value.anyOf) && branches.length > 0 && branches.length <= MAX_ANY_OF) + if (!risky) result.oneOf = branches.map((branch) => branch.schema) + if (risky && useBranches) result.anyOf = branches.map((branch) => branch.schema) + if (useBranches) context.properties = nested.properties + unsafe ||= risky continue } if (SCHEMA_LISTS.has(key) && Array.isArray(item)) { - result[key] = item.map((schema) => project(schema, context, depth + 1)) + result[key] = item.map((schema) => keep(child(schema))) + continue + } + if (key === "not" && isRecord(item)) { + const nested = { ...context } + const schema = child(item, depth, nested) + if (!schema.unsafe) { + result.not = schema.schema + context.properties = nested.properties + } else { + unsafe = true + } + continue + } + if (key === "if" || key === "then" || key === "else") { + if (condition?.unsafe) { + unsafe = true + continue + } + if (key === "if" && condition) { + result.if = condition.schema + continue + } + if (isRecord(item)) { + result[key] = keep(child(item)) + continue + } + result[key] = item continue } if (key === "properties" && isRecord(item)) { @@ -104,89 +197,134 @@ function project(value: unknown, context: Context, depth: number): JsonRecord { const entries = Object.entries(item).slice(0, remaining) context.properties += entries.length truncatedProperties = entries.length !== Object.keys(item).length - result.properties = Object.fromEntries( - entries.map(([name, schema]) => [name, project(schema, context, depth + 1)]), - ) + result.properties = Object.fromEntries(entries.map(([name, schema]) => [name, keep(child(schema, depth + 1))])) + unsafe ||= truncatedProperties continue } if (SCHEMA_MAPS.has(key) && isRecord(item)) { + const schemas = Object.entries(item).map(([name, schema]) => [name, child(schema)] as const) result[key] = Object.fromEntries( - Object.entries(item).map(([name, schema]) => [name, project(schema, context, depth + 1)]), + schemas.map(([name, schema]) => [name, typeof schema.schema.$ref === "string" ? {} : schema.schema]), ) + unsafe ||= schemas.some(([, schema]) => schema.unsafe) + continue + } + if (key === "contains" && isRecord(item)) { + const schema = child(item) + result.contains = schema.schema + widenedContains = schema.unsafe + keep(schema) continue } if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(item)) { - result[key] = project(item, context, depth + 1) + result[key] = keep(child(item)) continue } result[key] = item } - projectRequired(result, context) + unsafe ||= projectRequired(result, context) if (truncatedProperties) delete result.additionalProperties - return projectEnum(result) + if (widenedContains && "maxContains" in result) { + delete result.maxContains + unsafe = true + } + const projected = projectEnum(result) + unsafe ||= projected.unsafe + if ("unevaluatedProperties" in projected.schema && unsafe) { + delete projected.schema.unevaluatedProperties + unsafe = true + } + return { schema: projected.schema, unsafe } } -function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number) { +function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number, recursion: number): Projection { const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"]) const parentTypes = schemaTypes(source.type) const parentEnum = enumValues("const" in source ? [source.const] : source.enum) const variants = Array.isArray(source.anyOf) ? source.anyOf : [] - if (variants.length > MAX_ANY_OF) return project(omit(source, ["anyOf"]), context, depth) - const branches = variants.filter(isRecord).flatMap((branch) => { - const types = intersectTypes(parentTypes, schemaTypes(branch.type)) + if (variants.length > MAX_ANY_OF) { + const projected = project(omit(source, ["anyOf", "unevaluatedProperties"]), context, depth, recursion + 1) + return { ...projected, unsafe: true } + } + const branches = variants.flatMap((branch) => { + if (branch === false) return [] + const item = branch === true ? {} : isRecord(branch) ? branch : undefined + if (!item) return [] + const types = intersectTypes(parentTypes, schemaTypes(item.type)) if (types.length === 0) return [] - const branchEnum = enumValues("const" in branch ? [branch.const] : branch.enum) + const branchEnum = enumValues("const" in item ? [item.const] : item.enum) const values = intersectEnums(parentEnum, branchEnum).filter((value) => types.some((type) => matchesType(value, type)), ) if ((parentEnum.length > 0 || branchEnum.length > 0) && values.length === 0) return [] - const merged = omit({ ...base, ...branch }, ["type", "enum", "const"]) - return [ - project( - { - ...merged, - type: types.length === 1 ? types[0] : types, - ...(values.length > 0 ? { enum: values } : {}), - }, - context, - depth + 1, - ), - ] + const merged = omit({ ...base, ...item }, ["type", "enum", "const"]) + const projected = project( + { + ...merged, + type: types.length === 1 ? types[0] : types, + ...(values.length > 0 ? { enum: values } : {}), + }, + context, + depth, + recursion + 1, + ) + return [projected.schema] }) - return collapse(branches) + return { schema: collapse(branches), unsafe: true } } -function projectEnum(source: JsonRecord) { +function projectEnum(source: JsonRecord): Projection { const result = { ...source } const values = enumValues(result.enum) if (values.length === 0) { + const unsafe = "enum" in result delete result.enum - return result + return { schema: result, unsafe } } const types = schemaTypes(result.type) if (types.length > 0) { const compatible = values.filter((value) => types.some((type) => matchesType(value, type))) - if (compatible.length > 0) result.enum = compatible - else delete result.enum - return result + if (compatible.length === 0) { + delete result.enum + return { schema: result, unsafe: true } + } + const nullable = + types.length === 2 && types.includes("null") && !types.includes("object") && !types.includes("array") + if (types.length === 1 || nullable) { + result.enum = compatible + return { schema: result, unsafe: !same(result.enum, source.enum) } + } + const base = omit(result, ["enum", "type"]) + return { + schema: collapse(groupEnum(compatible).map((group) => ({ ...base, type: group.type, enum: group.values }))), + unsafe: true, + } } const groups = groupEnum(values) if (groups.length === 1) { result.type = groups[0]?.type result.enum = groups[0]?.values - return result + return { schema: result, unsafe: true } } const base = omit(result, ["enum", "type"]) return { - anyOf: groups.map((group) => ({ ...base, type: group.type, enum: group.values })), + schema: { anyOf: groups.map((group) => ({ ...base, type: group.type, enum: group.values })) }, + unsafe: true, } } function projectRequired(schema: JsonRecord, context: Context) { - if (schema.type !== "object" || !Array.isArray(schema.required)) return - const properties = isRecord(schema.properties) ? { ...schema.properties } : {} + if (!Array.isArray(schema.required)) return false + const types = schemaTypes(schema.type) + if (types.length > 0 && !types.includes("object")) { + delete schema.required + return true + } + const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined + const properties = { ...sourceProperties } + const sourceRequired = schema.required const required = [...new Set(schema.required.filter((item): item is string => typeof item === "string"))].filter( (name) => { if (Object.hasOwn(properties, name)) return true @@ -198,20 +336,25 @@ function projectRequired(schema: JsonRecord, context: Context) { ) schema.properties = properties schema.required = required + return ( + !sourceProperties || + !same(Object.keys(sourceProperties), Object.keys(properties)) || + !same(sourceRequired, required) + ) } function fitSize(schema: JsonRecord) { if (schemaSize(schema) <= MAX_SCHEMA_SIZE) return schema const compact = stripAnnotations(schema) if (schemaSize(compact) <= MAX_SCHEMA_SIZE) return compact - return { type: "object", properties: {} } + return {} } function stripAnnotations(schema: JsonRecord): JsonRecord { return Object.fromEntries( Object.entries(schema).flatMap(([key, value]) => { if (["description", "title", "default", "examples", "$comment"].includes(key)) return [] - if ((key === "properties" || key === "$defs") && isRecord(value)) { + if ((key === "properties" || key === "$defs" || SCHEMA_MAPS.has(key)) && isRecord(value)) { return [ [ key, @@ -221,10 +364,13 @@ function stripAnnotations(schema: JsonRecord): JsonRecord { ], ] } - if (Array.isArray(value)) { + if ((key === "anyOf" || key === "oneOf" || SCHEMA_LISTS.has(key)) && Array.isArray(value)) { return [[key, value.map((item) => (isRecord(item) ? stripAnnotations(item) : item))]] } - return [[key, isRecord(value) ? stripAnnotations(value) : value]] + if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(value)) { + return [[key, stripAnnotations(value)]] + } + return [[key, value]] }), ) } @@ -260,7 +406,30 @@ function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): n next.add(schema.$ref) return schemaDepth(target, root, next) })() - return Math.max(properties, ...nodes, ...lists, ...definitions, ref) + return [...nodes, ...lists, ...definitions, ref].reduce((max, value) => Math.max(max, value), properties) +} + +function dropDanglingRefs(schema: JsonRecord, root: JsonRecord): JsonRecord { + if (typeof schema.$ref === "string" && !resolveOutputRef(schema.$ref, root)) return {} + return Object.fromEntries( + Object.entries(schema).map(([key, value]) => { + if ((key === "properties" || key === "$defs" || SCHEMA_MAPS.has(key)) && isRecord(value)) { + return [ + key, + Object.fromEntries( + Object.entries(value).map(([name, item]) => [name, isRecord(item) ? dropDanglingRefs(item, root) : item]), + ), + ] + } + if ((key === "anyOf" || key === "oneOf" || SCHEMA_LISTS.has(key)) && Array.isArray(value)) { + return [key, value.map((item) => (isRecord(item) ? dropDanglingRefs(item, root) : item))] + } + if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(value)) { + return [key, dropDanglingRefs(value, root)] + } + return [key, value] + }), + ) } function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { @@ -277,9 +446,56 @@ function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined }, undefined) } +function terminates(schema: JsonRecord, root: JsonRecord, refs: Set): boolean { + const types = schemaTypes(schema.type) + if (types.some((type) => type !== "object" && type !== "array")) return true + if (types.includes("array")) { + if (!isRecord(schema.items) || Object.keys(schema.items).length === 0) return true + if (terminates(schema.items, root, refs)) return true + } + if (types.includes("object")) { + if (!Array.isArray(schema.required) || schema.required.length === 0) return true + const properties = isRecord(schema.properties) ? schema.properties : undefined + if (!properties || Object.keys(properties).length === 0) return true + if ( + schema.required.some((name) => { + const property = typeof name === "string" ? properties[name] : undefined + return isRecord(property) && terminates(property, root, refs) + }) + ) { + return true + } + } + if ( + Array.isArray(schema.anyOf) && + (schema.anyOf.length === 0 || schema.anyOf.some((item) => isRecord(item) && terminates(item, root, new Set(refs)))) + ) { + return true + } + if (typeof schema.$ref === "string") { + if (refs.has(schema.$ref)) return false + const target = resolveOutputRef(schema.$ref, root) + if (!target) return true + const next = new Set(refs) + next.add(schema.$ref) + return terminates(target, root, next) + } + return Object.keys(schema).length === 0 +} + function canonicalRef(value: unknown, context: Context) { if (typeof value !== "string") return - const ref = value.replace("#/definitions/", "#/$defs/") + if (value.includes("~0") || value.includes("~1")) return + const ref = (() => { + if (!value.startsWith("#/definitions/")) return value + const parts = value.slice("#/definitions/".length).split("/") + const name = parts[0]?.replaceAll("~1", "/").replaceAll("~0", "~") + if (!name || !context.legacy[name]) return + return `#/$defs/${context.legacy[name]?.replaceAll("~", "~0").replaceAll("/", "~1")}${ + parts.length > 1 ? `/${parts.slice(1).join("/")}` : "" + }` + })() + if (!ref) return if (ref === "#") return ref if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") @@ -300,10 +516,29 @@ function resolveRef(ref: string, context: Context): unknown { } function definitions(root: JsonRecord) { - return { - ...(isRecord(root.definitions) ? root.definitions : {}), - ...(isRecord(root.$defs) ? root.$defs : {}), - } + const modern = isRecord(root.$defs) ? root.$defs : {} + const schemas = { ...modern } + const legacy = Object.fromEntries( + Object.entries(isRecord(root.definitions) ? root.definitions : {}).map(([name, schema]) => { + const target = + !Object.hasOwn(schemas, name) || same(schemas[name], schema) ? name : uniqueDefinition(name, schemas) + schemas[target] = schema + return [name, target] + }), + ) + return { schemas, legacy } +} + +function uniqueDefinition(name: string, schemas: JsonRecord, index = 1): string { + const candidate = `${name}__definitions${index === 1 ? "" : `_${index}`}` + if (!Object.hasOwn(schemas, candidate)) return candidate + return uniqueDefinition(name, schemas, index + 1) +} + +function containsSlashKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsSlashKey) + if (!isRecord(value)) return false + return Object.entries(value).some(([key, item]) => key.includes("/") || containsSlashKey(item)) } function schemaTypes(value: unknown) { @@ -373,6 +608,22 @@ function unique(values: T[]) { return [...new Map(values.map((value) => [JSON.stringify(value), value])).values()] } +function same(left: unknown, right: unknown) { + return JSON.stringify(left) === JSON.stringify(right) +} + +function containsRef(value: unknown): boolean { + if (!isRecord(value)) return false + if (typeof value.$ref === "string") return true + const maps = [value.properties, value.$defs, value.definitions, ...[...SCHEMA_MAPS].map((key) => value[key])] + if (maps.some((map) => isRecord(map) && Object.values(map).some(containsRef))) return true + const nodes = [value.items, ...[...SCHEMA_NODES].map((key) => value[key])] + if (nodes.some(containsRef)) return true + return [value.anyOf, value.oneOf, ...[...SCHEMA_LISTS].map((key) => value[key])].some( + (items) => Array.isArray(items) && items.some(containsRef), + ) +} + function omit(source: JsonRecord, keys: string[]) { const omitted = new Set(keys) return Object.fromEntries(Object.entries(source).filter(([key]) => !omitted.has(key))) From bc3686b8dfb42f5657f435db213b31729f31b5cb Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 20:38:32 +0200 Subject: [PATCH 6/6] fix(opencode): improve MFJS schema sanitizing --- packages/opencode/src/provider/mfjs.ts | 29 +++++++++--- packages/opencode/src/provider/transform.ts | 6 ++- packages/opencode/test/provider/mfjs.test.ts | 44 +++++++++++++++++++ .../opencode/test/provider/transform.test.ts | 1 + 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index e0df83d584..e976df317f 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -102,7 +102,9 @@ function project(value: unknown, context: Context, depth: number, recursion: num const inferredTypes = declaredTypes.length > 0 ? declaredTypes - : groupEnum(enumValues("const" in value ? [value.const] : value.enum)).map((group) => group.type) + : hasUnprojectableEnumValue("const" in value ? [value.const] : value.enum, []) + ? [] + : groupEnum(enumValues("const" in value ? [value.const] : value.enum)).map((group) => group.type) if (Array.isArray(value.anyOf) && inferredTypes.length > 0) { return projectTypedAnyOf( { ...value, type: inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes }, @@ -240,7 +242,8 @@ function project(value: unknown, context: Context, depth: number, recursion: num function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number, recursion: number): Projection { const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"]) const parentTypes = schemaTypes(source.type) - const parentEnum = enumValues("const" in source ? [source.const] : source.enum) + const parentValues = "const" in source ? [source.const] : source.enum + const parentEnum = hasUnprojectableEnumValue(parentValues, parentTypes) ? [] : enumValues(parentValues) const variants = Array.isArray(source.anyOf) ? source.anyOf : [] if (variants.length > MAX_ANY_OF) { const projected = project(omit(source, ["anyOf", "unevaluatedProperties"]), context, depth, recursion + 1) @@ -252,7 +255,8 @@ function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number, if (!item) return [] const types = intersectTypes(parentTypes, schemaTypes(item.type)) if (types.length === 0) return [] - const branchEnum = enumValues("const" in item ? [item.const] : item.enum) + const branchValues = "const" in item ? [item.const] : item.enum + const branchEnum = hasUnprojectableEnumValue(branchValues, types) ? [] : enumValues(branchValues) const values = intersectEnums(parentEnum, branchEnum).filter((value) => types.some((type) => matchesType(value, type)), ) @@ -275,6 +279,11 @@ function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number, function projectEnum(source: JsonRecord): Projection { const result = { ...source } + const types = schemaTypes(result.type) + if (hasUnprojectableEnumValue(result.enum, types)) { + delete result.enum + return { schema: result, unsafe: true } + } const values = enumValues(result.enum) if (values.length === 0) { const unsafe = "enum" in result @@ -282,7 +291,6 @@ function projectEnum(source: JsonRecord): Projection { return { schema: result, unsafe } } - const types = schemaTypes(result.type) if (types.length > 0) { const compatible = values.filter((value) => types.some((type) => matchesType(value, type))) if (compatible.length === 0) { @@ -458,9 +466,9 @@ function terminates(schema: JsonRecord, root: JsonRecord, refs: Set): bo const properties = isRecord(schema.properties) ? schema.properties : undefined if (!properties || Object.keys(properties).length === 0) return true if ( - schema.required.some((name) => { + schema.required.every((name) => { const property = typeof name === "string" ? properties[name] : undefined - return isRecord(property) && terminates(property, root, refs) + return !isRecord(property) || terminates(property, root, refs) }) ) { return true @@ -566,6 +574,15 @@ function enumValues(value: unknown) { return values.length > MAX_ENUM ? [] : values } +function hasUnprojectableEnumValue(value: unknown, types: string[]) { + if (!Array.isArray(value)) return false + return value.some((item) => { + if (valueType(item)) return false + const type = Array.isArray(item) ? "array" : isRecord(item) ? "object" : undefined + return type !== undefined && (types.length === 0 || types.includes(type)) + }) +} + function intersectEnums(parent: unknown[], child: unknown[]) { if (parent.length === 0) return child if (child.length === 0) return parent diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c9cea560cd..5a01d8d92b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1463,7 +1463,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. } - if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { + if ( + model.providerID === "moonshotai" || + model.family?.toLowerCase().startsWith("kimi") || + model.api.id.toLowerCase().includes("kimi") + ) { schema = MFJS.sanitize(schema) } diff --git a/packages/opencode/test/provider/mfjs.test.ts b/packages/opencode/test/provider/mfjs.test.ts index 5f8f2af5ce..f6cd04fc71 100644 --- a/packages/opencode/test/provider/mfjs.test.ts +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -100,6 +100,30 @@ describe("MFJS.sanitize", () => { }) }) + test("widens enums when structured values remain possible", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + untyped: { enum: ["text", { kind: "legacy" }] }, + excluded: { type: "string", enum: ["text", { kind: "legacy" }] }, + union: { + type: ["string", "object"], + enum: ["text", { kind: "legacy" }], + anyOf: [{ type: "string" }, { type: "object" }], + }, + }, + }), + ).toEqual({ + type: "object", + properties: { + untyped: {}, + excluded: { type: "string", enum: ["text"] }, + union: { anyOf: [{ type: "string" }, { type: "object" }] }, + }, + }) + }) + test("drops tuple items instead of narrowing positional schemas", () => { expect( MFJS.sanitize({ @@ -216,6 +240,26 @@ describe("MFJS.sanitize", () => { }) }) + test("drops recursive schemas with no finite instance", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + required: ["node"], + $defs: { + Node: { + type: "object", + properties: { + value: { type: "string" }, + next: { $ref: "#/$defs/Node" }, + }, + required: ["value", "next"], + }, + }, + }), + ).toEqual({ type: "object", properties: {} }) + }) + test("adds unconstrained schemas for dangling required properties", () => { expect( MFJS.sanitize({ diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dc7e503cd0..7d25098241 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1532,6 +1532,7 @@ 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" } }], + ["Kimi model families", { providerID: "custom", family: "kimi-k2", api: { id: "alias" } }], ] as const for (const [name, model] of models) {