1import { assert, describe, it } from "@effect/vitest"
2import { type JsonSchema, Schema } from "effect"
3import { toCodecOpenAI } from "effect/ai/OpenAiStructuredOutput"
4import { TestSchema } from "effect/testing"
5
6function getValueSchema(jsonSchema: JsonSchema.JsonSchema): JsonSchema.JsonSchema {
7 return (jsonSchema.properties as Record<string, JsonSchema.JsonSchema>).value
8}
9
10function toValueSchema(schema: Schema.Constraint): JsonSchema.JsonSchema {
11 return getValueSchema(toCodecOpenAI(Schema.Struct({ value: schema })).jsonSchema)
12}
13
14describe("OpenAiStructuredOutput representation v2", () => {
15 it("projects the encoded side before JSON Schema generation", () => {
16 assert.strictEqual(toValueSchema(Schema.FiniteFromString).type, "string")
17 })
18
19 it("keeps supported custom JSON Schema filters with a description", () => {
20 const schema = Schema.String.check(Schema.makeFilter<string>((value) => value.startsWith("a"), {
21 description: "starts with a",
22 representation: {
23 id: "test/ai/openai/startsWithA",
24 payload: null
25 },
26 toJsonSchema: () => ({ pattern: "^a" })
27 }))
28
29 assert.deepStrictEqual(toValueSchema(schema), {
30 type: "string",
31 description: "starts with a",
32 pattern: "^a"
33 })
34 })
35
36 it("drops unsupported custom JSON Schema filters with a description", async () => {
37 const schema = Schema.String.check(Schema.makeFilter<string>((value) => value.length >= 2, {
38 description: "at least two characters",
39 representation: {
40 id: "test/ai/openai/minTwoCharactersWithDescription",
41 payload: null
42 },
43 toJsonSchema: () => ({ minLength: 2 })
44 }))
45
46 const result = toCodecOpenAI(Schema.Struct({ value: schema }))
47 assert.deepStrictEqual(getValueSchema(result.jsonSchema), {
48 type: "string",
49 description: "at least two characters"
50 })
51 await new TestSchema.Asserts(result.codec).decoding().fail(
52 { value: "a" },
53 `Expected <filter>
54 at ["value"]`
55 )
56 })
57
58 it("keeps supported custom JSON Schema filters without a description", () => {
59 const schema = Schema.String.check(Schema.makeFilter<string>((value) => value.startsWith("a"), {
60 representation: {
61 id: "test/ai/openai/startsWithAWithoutDescription",
62 payload: null
63 },
64 toJsonSchema: () => ({ pattern: "^a" })
65 }))
66
67 assert.deepStrictEqual(toValueSchema(schema), {
68 type: "string",
69 pattern: "^a"
70 })
71 })
72
73 it("drops unsupported custom JSON Schema filters without a description", () => {
74 const schema = Schema.String.check(Schema.makeFilter<string>((value) => value.length >= 2, {
75 representation: {
76 id: "test/ai/openai/minTwoCharactersWithoutDescription",
77 payload: null
78 },
79 toJsonSchema: () => ({ minLength: 2 })
80 }))
81
82 assert.deepStrictEqual(toValueSchema(schema), {
83 type: "string"
84 })
85 })
86
87 it("invokes custom JSON Schema callbacks only with compiler inputs", () => {
88 let invocations = 0
89 const schema = Schema.String.check(Schema.makeFilter<string>(() => true, {
90 representation: {
91 id: "test/ai/openai/compilerInputs",
92 payload: null,
93 schemas: [Schema.Finite.ast]
94 },
95 toJsonSchema: ({ type, schemas }) => {
96 invocations++
97 assert.strictEqual(type, "string")
98 assert.strictEqual(schemas.length, 1)
99 assert.strictEqual(schemas[0].type, "number")
100 return { pattern: "^a" }
101 }
102 }))
103
104 assert.deepStrictEqual(toValueSchema(schema), {
105 type: "string",
106 pattern: "^a"
107 })
108 assert.strictEqual(invocations, 1)
109 })
110
111 it("compiles custom callbacks after structural transformations", () => {
112 let invocations = 0
113 const schema = Schema.Record(Schema.String, Schema.Finite).check(
114 Schema.makeFilter<object>(() => true, {
115 representation: {
116 id: "test/ai/openai/structuralCompilerInput",
117 payload: null
118 },
119 toJsonSchema: ({ type }) => {
120 invocations++
121 assert.strictEqual(type, "array")
122 return { minItems: 1 }
123 }
124 })
125 )
126
127 const jsonSchema = toValueSchema(schema)
128 assert.strictEqual(jsonSchema.type, "array")
129 assert.strictEqual(jsonSchema.minItems, 1)
130 assert.strictEqual(invocations, 1)
131 })
132
133 it("keeps property names that match unsupported keywords", () => {
134 const unsupported = Schema.Any.check(Schema.makeFilter<unknown>(() => true, {
135 representation: {
136 id: "test/ai/openai/unsupportedPropertySchema",
137 payload: null
138 },
139 toJsonSchema: () => ({ minLength: 2 })
140 }))
141
142 assert.deepStrictEqual(toCodecOpenAI(Schema.Struct({ minLength: unsupported })).jsonSchema, {
143 type: "object",
144 properties: { minLength: {} },
145 required: ["minLength"],
146 additionalProperties: false
147 })
148 })
149
150 it("drops patternProperties returned by custom callbacks", () => {
151 const schema = Schema.Struct({ fixed: Schema.String }).check(Schema.makeFilter<object>(() => true, {
152 representation: {
153 id: "test/ai/openai/patternProperties",
154 payload: null
155 },
156 toJsonSchema: () => ({
157 patternProperties: {
158 "^x": { type: "string" }
159 }
160 })
161 }))
162
163 assert.deepStrictEqual(toCodecOpenAI(schema).jsonSchema, {
164 type: "object",
165 properties: { fixed: { type: "string" } },
166 required: ["fixed"],
167 additionalProperties: false
168 })
169 })
170
171 it("does not merge structural constraints from allOf", () => {
172 const schema = Schema.Struct({ fixed: Schema.String }).check(Schema.makeFilter<object>(() => true, {
173 representation: {
174 id: "test/ai/openai/structuralAllOf",
175 payload: null
176 },
177 toJsonSchema: () => ({
178 properties: {
179 extra: { type: "string" }
180 },
181 required: ["extra"]
182 })
183 }))
184
185 assert.deepStrictEqual(toCodecOpenAI(schema).jsonSchema, {
186 type: "object",
187 properties: { fixed: { type: "string" } },
188 required: ["fixed"],
189 additionalProperties: false
190 })
191 })
192
193 it("keeps the original codec on the unchanged fast path", () => {
194 const schema = Schema.Struct({ value: Schema.String })
195 assert.strictEqual(toCodecOpenAI(schema).codec, schema)
196 })
197
198 it("keeps declaration fallback validation in the codec", () => {
199 class Opaque {
200 readonly _tag = "Opaque"
201 }
202 const schema = Schema.declare<Opaque>((input): input is Opaque => input instanceof Opaque, {
203 expected: "Opaque"
204 })
205 const result = toCodecOpenAI(Schema.Struct({ value: schema }))
206 assert.ok(Object.keys(getValueSchema(result.jsonSchema)).length > 0)
207 assert.strictEqual(Schema.decodeUnknownExit(result.codec)({ value: {} })._tag, "Failure")
208 })
209
210 it("merges repeated lower bounds conservatively", () => {
211 assert.deepStrictEqual(
212 toValueSchema(Schema.Finite.check(
213 Schema.isGreaterThan(1),
214 Schema.isGreaterThanOrEqualTo(2),
215 Schema.isGreaterThan(2)
216 )),
217 {
218 type: "number",
219 description: "a value greater than 1 and a value greater than or equal to 2 and a value greater than 2",
220 exclusiveMinimum: 2
221 }
222 )
223 })
224
225 it("merges repeated array bounds conservatively", () => {
226 const jsonSchema = toValueSchema(
227 Schema.Array(Schema.String).check(
228 Schema.isMinLength(1),
229 Schema.isMinLength(2),
230 Schema.isMaxLength(5),
231 Schema.isMaxLength(4)
232 )
233 )
234 assert.strictEqual(jsonSchema.minItems, 2)
235 assert.strictEqual(jsonSchema.maxItems, 4)
236 })
237
238 it("transports only sound record size bounds to entry arrays", () => {
239 const maximum = toValueSchema(
240 Schema.Record(Schema.String, Schema.Finite).check(Schema.isMaxProperties(3))
241 )
242 assert.isUndefined(maximum.maxItems)
243
244 const between = toValueSchema(
245 Schema.Record(Schema.String, Schema.Finite).check(Schema.isBetweenProperties(1, 3))
246 )
247 assert.strictEqual(between.minItems, 1)
248 assert.isUndefined(between.maxItems)
249 })
250})