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 000000000000..72a490880b56 --- /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 new file mode 100644 index 000000000000..e976df317fc0 --- /dev/null +++ b/packages/opencode/src/provider/mfjs.ts @@ -0,0 +1,651 @@ +export * as MFJS from "./mfjs" + +import type { JSONSchema7 } from "@ai-sdk/provider" + +/** + * 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. + * + * 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/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 + */ + +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"]) +const SCHEMA_NODES = new Set([ + "additionalProperties", + "not", + "if", + "then", + "else", + "contains", + "propertyNames", + "unevaluatedProperties", +]) +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 + +/** + * 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 = isRecord(value) ? value : {} + 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, containsSlashKey(schema) ? {} : project(schema, context, 0, 0).schema]), + ) + if (Object.keys(defs).length > 0) projected.$defs = defs + const resolved = dropDanglingRefs(projected, projected) + const bounded = + (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, 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) { + 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, + } + } + + const declaredTypes = schemaTypes(value.type) + const inferredTypes = + declaredTypes.length > 0 + ? declaredTypes + : 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 }, + 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)) { + unsafe = true + continue + } + if (key === "items" && (item === true || item === false)) { + result.items = {} + unsafe = true + continue + } + if (key === "anyOf" && Array.isArray(item)) { + 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) => 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)) { + 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, 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( + 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] = keep(child(item)) + continue + } + result[key] = item + } + unsafe ||= projectRequired(result, context) + if (truncatedProperties) delete result.additionalProperties + 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, recursion: number): Projection { + const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"]) + const parentTypes = schemaTypes(source.type) + 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) + 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 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)), + ) + if ((parentEnum.length > 0 || branchEnum.length > 0) && values.length === 0) return [] + 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 { schema: collapse(branches), unsafe: true } +} + +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 + delete result.enum + return { schema: result, unsafe } + } + + if (types.length > 0) { + const compatible = values.filter((value) => types.some((type) => matchesType(value, type))) + 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 { schema: result, unsafe: true } + } + const base = omit(result, ["enum", "type"]) + return { + schema: { anyOf: groups.map((group) => ({ ...base, type: group.type, enum: group.values })) }, + unsafe: true, + } +} + +function projectRequired(schema: JsonRecord, context: Context) { + 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 + if (context.properties >= MAX_PROPERTIES) return false + context.properties++ + properties[name] = {} + return true + }, + ) + 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 {} +} + +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" || SCHEMA_MAPS.has(key)) && isRecord(value)) { + return [ + [ + key, + Object.fromEntries( + Object.entries(value).map(([name, item]) => [name, isRecord(item) ? stripAnnotations(item) : item]), + ), + ], + ] + } + if ((key === "anyOf" || key === "oneOf" || SCHEMA_LISTS.has(key)) && Array.isArray(value)) { + return [[key, value.map((item) => (isRecord(item) ? stripAnnotations(item) : item))]] + } + if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(value)) { + return [[key, stripAnnotations(value)]] + } + return [[key, value]] + }), + ) +} + +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 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 lists = [schema.anyOf, schema.oneOf, schema.allOf, schema.prefixItems].flatMap((items) => + Array.isArray(items) ? items.flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) : [], + ) + const definitions = isRecord(schema.$defs) + ? Object.values(schema.$defs).flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) + : [] + 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 [...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 { + 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 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.every((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 + 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", "~") + if (!name || name.includes("/")) return + return isRecord(resolveRef(ref, context)) ? ref : undefined +} + +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 context.definitions[part] + return isRecord(current) ? current[part] : undefined + }, undefined) +} + +function definitions(root: JsonRecord) { + 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) { + 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 intersectTypes(parent: string[], child: string[]) { + if (child.length === 0) return parent + return [ + ...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 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 + return parent.filter((value) => child.some((item) => Object.is(value, item))) +} + +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 matchesType(value: unknown, type: string) { + const actual = valueType(value) + if (type === "number") return actual === "number" || actual === "integer" + return actual === type +} + +function collapse(branches: JsonRecord[]) { + const projected = unique(branches) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] ?? {} + return { anyOf: projected } +} + +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))) +} + +function isRecord(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 d6f817b50e4f..5a01d8d92b3c 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" @@ -1462,22 +1463,12 @@ 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")) { - 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 - } + if ( + model.providerID === "moonshotai" || + model.family?.toLowerCase().startsWith("kimi") || + model.api.id.toLowerCase().includes("kimi") + ) { + 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 000000000000..f6cd04fc71a1 --- /dev/null +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -0,0 +1,354 @@ +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 reference siblings and normalizes draft-07 definitions", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + value: { $ref: "#/definitions/Value", description: "drop me" }, + }, + definitions: { + Value: { type: "object", description: "keep me" }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { Value: { type: "object", description: "keep me" } }, + }) + }) + + test("preserves nullable reference semantics", () => { + expect( + MFJS.sanitize({ + type: "object", + 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: "object" } }, + required: ["operation"], + }) + }) + + test("removes only enum values excluded by an explicit type", () => { + expect( + MFJS.sanitize({ + type: "object", + 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: { + value: { + anyOf: [ + { type: "string", enum: ["move"] }, + { type: "integer", enum: [1] }, + ], + }, + }, + }) + }) + + 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({ + type: "object", + properties: { + values: { + type: "array", + items: [{ type: "string" }, { type: "number" }], + minItems: 2, + }, + }, + }), + ).toEqual({ + type: "object", + properties: { values: { type: "array", minItems: 2 } }, + }) + }) + + test("preserves prefixItems accepted by Kimi", () => { + expectProjection( + { + type: "object", + properties: { + values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + }, + }, + { + type: "object", + properties: { + values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + }, + }, + ) + }) + + test("moves parent types into compatible 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("preserves type arrays and nullable accepted by Kimi", () => { + expectProjection( + { + type: "object", + properties: { + typed: { type: ["string", "null"], enum: ["move", null] }, + nullable: { type: "string", nullable: true }, + }, + }, + { + type: "object", + properties: { + typed: { type: ["string", "null"], enum: ["move", null] }, + nullable: { type: "string", nullable: true }, + }, + }, + ) + }) + + 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", + properties: { operation: { const: "move" } }, + }), + ).toEqual({ + type: "object", + properties: { operation: { const: "move" } }, + }) + }) + + test("drops unresolved references", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { $ref: "#/definitions/Missing" } }, + }), + ).toEqual({ + type: "object", + properties: { value: {} }, + }) + }) + + 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({ + 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: { + value: { type: "string", enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`) }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: { type: "string" } }, + }) + + expect( + MFJS.sanitize({ + type: "object", + properties: { + value: { anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })) }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: {} }, + }) + + 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) + + 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", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { Value: definition }, + }), + ).toEqual({ type: "object", properties: {} }) + }) + + test("is idempotent", () => { + const once = MFJS.sanitize({ + type: "object", + properties: { + operation: { type: "object", enum: ["move"] }, + values: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + }, + }) + 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 c5cea6b797fd..7d25098241dc 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1528,148 +1528,30 @@ describe("ProviderTransform.schema - openai supported schema subset", () => { }) }) -describe("ProviderTransform.schema - moonshot $ref siblings", () => { - const moonshotModel = { - providerID: "moonshotai", - api: { - id: "kimi-k2", - }, - } as any - - 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.", +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) { + test(`sanitizes ${name}`, () => { + expect( + ProviderTransform.schema(model as Parameters[0], { + type: "object", 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", - }, + operation: { type: "object", enum: ["move", "copy"] }, }, - 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, - { + }), + ).toEqual({ type: "object", properties: { - value: { - $ref: "#/$defs/Value", - description: "Moonshot rejects this sibling after ref expansion.", - }, - }, - $defs: { - Value: { - description: "Referenced schema description stays here.", - type: "object", - }, - }, - } as any, - ) as any - - expect(result.properties.value).toEqual({ - $ref: "#/$defs/Value", - }) - }) - - test("converts tuple-style array items to a single item schema", () => { - const result = ProviderTransform.schema(moonshotModel, { - type: "object", - properties: { - codeSpec: { - type: "object", - properties: { - accessibility: { - type: "object", - properties: { - renderedSize: { - description: "Rendered size [width, height] in px", - type: "array", - items: [{ type: "number" }, { type: "number" }], - minItems: 2, - maxItems: 2, - }, - }, - }, - }, + operation: { type: "object" }, }, - }, - } as any) as any - - expect(result.properties.codeSpec.properties.accessibility.properties.renderedSize.items).toEqual({ - type: "number", + }) }) - }) + } }) describe("ProviderTransform.message - DeepSeek reasoning content", () => {