From bc3686b8dfb42f5657f435db213b31729f31b5cb Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 20:38:32 +0200 Subject: [PATCH] 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) {