diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 0aa4e2068efe..e467c2964e80 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -6,9 +6,9 @@ import { TuiConfig } from "@opencode-ai/tui/config" import { Schema } from "effect" type JsonSchema = Record -const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" +export const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" -function generateEffect(schema: Schema.Top) { +export function generateEffect(schema: Schema.Top) { const document = Schema.toJsonSchemaDocument(schema) const normalized = normalize({ $schema: "https://json-schema.org/draft/2020-12/schema", @@ -50,13 +50,15 @@ function normalize(value: unknown): unknown { return schema } -function restoreModelRefs(value: unknown, key?: string): unknown { +export function restoreModelRefs(value: unknown, key?: string): unknown { if (Array.isArray(value)) return value.map((item) => restoreModelRefs(item)) if (!isRecord(value)) return value const schema = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, restoreModelRefs(item, name)])) if ((key === "model" || key === "small_model") && schema.type === "string") { - return { ...schema, $ref: MODEL_REF } + const { type: _, ...rest } = schema + // models.dev Model is a closed enum; sibling $ref rejects custom ids. + return { ...rest, anyOf: [{ type: "string" }, { $ref: MODEL_REF }] } } return schema } @@ -65,13 +67,15 @@ function isRecord(value: unknown): value is JsonSchema { return typeof value === "object" && value !== null && !Array.isArray(value) } -const configFile = process.argv[2] -const tuiFile = process.argv[3] +if (import.meta.main) { + const configFile = process.argv[2] + const tuiFile = process.argv[3] -console.log(configFile) -await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null, 2)) + console.log(configFile) + await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null, 2)) -if (tuiFile) { - console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2)) + if (tuiFile) { + console.log(tuiFile) + await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2)) + } } diff --git a/packages/opencode/test/script/schema.test.ts b/packages/opencode/test/script/schema.test.ts new file mode 100644 index 000000000000..0868f1fdece7 --- /dev/null +++ b/packages/opencode/test/script/schema.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { generateEffect, MODEL_REF, restoreModelRefs } from "../../script/schema" + +const CUSTOM = "internal/Qwen/Qwen3-Coder-30B-A3B-Instruct" +const KNOWN = "anthropic/claude-2" +const KNOWN_MODELS = [KNOWN] as const + +const ISSUE_EXAMPLE = { + $schema: "https://opencode.ai/config.json", + provider: { + internal: { + npm: "@ai-sdk/openai-compatible", + models: { + "Qwen/Qwen3-Coder-30B-A3B-Instruct": {}, + }, + }, + }, + model: CUSTOM, +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** + * JSON Schema 2020-12: `$ref` is applied together with sibling keywords. + * Stub models.dev `$defs/Model` as a closed enum so this test does not fetch. + */ +function acceptsModelId(schema: unknown, value: string, known: readonly string[]): boolean { + if (!isRecord(schema)) return false + if (Array.isArray(schema.anyOf)) { + return schema.anyOf.some((option) => acceptsModelId(option, value, known)) + } + const refOk = schema.$ref !== MODEL_REF || known.includes(value) + const typeOk = schema.type === undefined || schema.type === "string" + if (schema.$ref === MODEL_REF || schema.type === "string") return refOk && typeOk + return false +} + +function collectModelIdSchemas(value: unknown, key?: string, out: unknown[] = []): unknown[] { + if (Array.isArray(value)) { + for (const item of value) collectModelIdSchemas(item, undefined, out) + return out + } + if (!isRecord(value)) return out + if ((key === "model" || key === "small_model") && isModelIdSchema(value)) out.push(value) + for (const [name, item] of Object.entries(value)) collectModelIdSchemas(item, name, out) + return out +} + +function isModelIdSchema(schema: Record): boolean { + if (schema.type === "string") return true + if (schema.$ref === MODEL_REF) return true + if (Array.isArray(schema.anyOf)) { + return schema.anyOf.some((item) => isRecord(item) && isModelIdSchema(item)) + } + return false +} + +const modelFields = { + type: "object", + properties: { + model: { + type: "string", + description: "Model to use in the format of provider/model, eg anthropic/claude-2", + }, + small_model: { + type: "string", + description: "Small model to use for tasks like title generation in the format of provider/model", + }, + agent: { + type: "object", + additionalProperties: { + type: "object", + properties: { + model: { type: "string" }, + }, + }, + }, + command: { + type: "object", + additionalProperties: { + type: "object", + properties: { + model: { type: "string" }, + }, + }, + }, + }, +} + +describe("restoreModelRefs", () => { + test("accepts custom provider/model from the published schema example", () => { + const schema = restoreModelRefs(modelFields) + const fields = collectModelIdSchemas(schema) + expect(fields.length).toBe(4) + for (const field of fields) { + expect(acceptsModelId(field, ISSUE_EXAMPLE.model, KNOWN_MODELS)).toBe(true) + } + }) + + test("still accepts a normal models.dev id as a string", () => { + const schema = restoreModelRefs(modelFields) + for (const field of collectModelIdSchemas(schema)) { + expect(acceptsModelId(field, KNOWN, KNOWN_MODELS)).toBe(true) + } + }) +}) + +describe("generated config schema", () => { + test("does not reject custom provider/model on Config.model and nested model fields", () => { + const schema = generateEffect(ConfigV1.Info) + const fields = collectModelIdSchemas(schema) + expect(fields.length).toBeGreaterThanOrEqual(4) + for (const field of fields) { + expect(acceptsModelId(field, CUSTOM, KNOWN_MODELS)).toBe(true) + expect(acceptsModelId(field, KNOWN, KNOWN_MODELS)).toBe(true) + } + }) +})