mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b8ea622c | |||
| bc3686b8df | |||
| d2a11baa99 | |||
| 50a29ff19f | |||
| 4d6f3f002c | |||
| fa041090f7 | |||
| ba5dd2f962 |
@@ -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<string, unknown>
|
||||
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<Result> {
|
||||
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<T>(jobs: Array<() => Promise<T>>, limit: number) {
|
||||
const output = new Array<T>(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<string, Map<string, Result>>()
|
||||
results.forEach((result) => {
|
||||
const row = byCase.get(result.case) ?? new Map<string, Result>()
|
||||
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<JsonRecord>(
|
||||
(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<object>()): 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)
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
type Context = {
|
||||
root: JsonRecord
|
||||
definitions: JsonRecord
|
||||
legacy: Record<string, string>
|
||||
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<string>): 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<JsonRecord | undefined>((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<string>): 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<unknown>((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<T>(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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<Record<string, unknown>>(
|
||||
(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<Record<string, unknown>>(
|
||||
(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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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.",
|
||||
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",
|
||||
},
|
||||
},
|
||||
for (const [name, model] of models) {
|
||||
test(`sanitizes ${name}`, () => {
|
||||
expect(
|
||||
ProviderTransform.schema(model as Parameters<typeof ProviderTransform.schema>[0], {
|
||||
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,
|
||||
{
|
||||
properties: {
|
||||
operation: { type: "object", enum: ["move", "copy"] },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
$ref: "#/$defs/Value",
|
||||
description: "Moonshot rejects this sibling after ref expansion.",
|
||||
},
|
||||
operation: { type: "object" },
|
||||
},
|
||||
$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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any) as any
|
||||
|
||||
expect(result.properties.codeSpec.properties.accessibility.properties.renderedSize.items).toEqual({
|
||||
type: "number",
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
||||
|
||||
Reference in New Issue
Block a user