fix(opencode): improve MFJS schema sanitizing

This commit is contained in:
starptech
2026-07-18 20:38:32 +02:00
parent d2a11baa99
commit bc3686b8df
4 changed files with 73 additions and 7 deletions
+23 -6
View File
@@ -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<string>): 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
+5 -1
View File
@@ -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)
}
@@ -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({
@@ -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) {