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", () => {