fix(opencode): improve MFJS schema sanitizing
This commit is contained in:
parent
d2a11baa99
commit
bc3686b8df
4 changed files with 73 additions and 7 deletions
|
|
@ -102,7 +102,9 @@ function project(value: unknown, context: Context, depth: number, recursion: num
|
||||||
const inferredTypes =
|
const inferredTypes =
|
||||||
declaredTypes.length > 0
|
declaredTypes.length > 0
|
||||||
? declaredTypes
|
? 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) {
|
if (Array.isArray(value.anyOf) && inferredTypes.length > 0) {
|
||||||
return projectTypedAnyOf(
|
return projectTypedAnyOf(
|
||||||
{ ...value, type: inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes },
|
{ ...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 {
|
function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number, recursion: number): Projection {
|
||||||
const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"])
|
const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"])
|
||||||
const parentTypes = schemaTypes(source.type)
|
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 : []
|
const variants = Array.isArray(source.anyOf) ? source.anyOf : []
|
||||||
if (variants.length > MAX_ANY_OF) {
|
if (variants.length > MAX_ANY_OF) {
|
||||||
const projected = project(omit(source, ["anyOf", "unevaluatedProperties"]), context, depth, recursion + 1)
|
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 []
|
if (!item) return []
|
||||||
const types = intersectTypes(parentTypes, schemaTypes(item.type))
|
const types = intersectTypes(parentTypes, schemaTypes(item.type))
|
||||||
if (types.length === 0) return []
|
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) =>
|
const values = intersectEnums(parentEnum, branchEnum).filter((value) =>
|
||||||
types.some((type) => matchesType(value, type)),
|
types.some((type) => matchesType(value, type)),
|
||||||
)
|
)
|
||||||
|
|
@ -275,6 +279,11 @@ function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number,
|
||||||
|
|
||||||
function projectEnum(source: JsonRecord): Projection {
|
function projectEnum(source: JsonRecord): Projection {
|
||||||
const result = { ...source }
|
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)
|
const values = enumValues(result.enum)
|
||||||
if (values.length === 0) {
|
if (values.length === 0) {
|
||||||
const unsafe = "enum" in result
|
const unsafe = "enum" in result
|
||||||
|
|
@ -282,7 +291,6 @@ function projectEnum(source: JsonRecord): Projection {
|
||||||
return { schema: result, unsafe }
|
return { schema: result, unsafe }
|
||||||
}
|
}
|
||||||
|
|
||||||
const types = schemaTypes(result.type)
|
|
||||||
if (types.length > 0) {
|
if (types.length > 0) {
|
||||||
const compatible = values.filter((value) => types.some((type) => matchesType(value, type)))
|
const compatible = values.filter((value) => types.some((type) => matchesType(value, type)))
|
||||||
if (compatible.length === 0) {
|
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
|
const properties = isRecord(schema.properties) ? schema.properties : undefined
|
||||||
if (!properties || Object.keys(properties).length === 0) return true
|
if (!properties || Object.keys(properties).length === 0) return true
|
||||||
if (
|
if (
|
||||||
schema.required.some((name) => {
|
schema.required.every((name) => {
|
||||||
const property = typeof name === "string" ? properties[name] : undefined
|
const property = typeof name === "string" ? properties[name] : undefined
|
||||||
return isRecord(property) && terminates(property, root, refs)
|
return !isRecord(property) || terminates(property, root, refs)
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
|
|
@ -566,6 +574,15 @@ function enumValues(value: unknown) {
|
||||||
return values.length > MAX_ENUM ? [] : values
|
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[]) {
|
function intersectEnums(parent: unknown[], child: unknown[]) {
|
||||||
if (parent.length === 0) return child
|
if (parent.length === 0) return child
|
||||||
if (child.length === 0) return parent
|
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.
|
// 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)
|
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", () => {
|
test("drops tuple items instead of narrowing positional schemas", () => {
|
||||||
expect(
|
expect(
|
||||||
MFJS.sanitize({
|
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", () => {
|
test("adds unconstrained schemas for dangling required properties", () => {
|
||||||
expect(
|
expect(
|
||||||
MFJS.sanitize({
|
MFJS.sanitize({
|
||||||
|
|
|
||||||
|
|
@ -1532,6 +1532,7 @@ describe("ProviderTransform.schema - MFJS selection", () => {
|
||||||
const models = [
|
const models = [
|
||||||
["Moonshot providers", { providerID: "moonshotai", api: { id: "kimi-k2" } }],
|
["Moonshot providers", { providerID: "moonshotai", api: { id: "kimi-k2" } }],
|
||||||
["Kimi API IDs", { providerID: "openrouter", api: { id: "moonshotai/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
|
] as const
|
||||||
|
|
||||||
for (const [name, model] of models) {
|
for (const [name, model] of models) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue