mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
fix(opencode): improve MFJS schema sanitizing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user