286 lines
9.9 KiB
TypeScript
286 lines
9.9 KiB
TypeScript
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)
|
|
}
|