1import { assert, describe, it } from "@effect/vitest"
2import { type JsonSchema, Schema } from "effect"
3import { toCodecAnthropic } from "effect/ai/AnthropicStructuredOutput"
4import * as Tool from "effect/ai/Tool"
5import { TestSchema } from "effect/testing"
6
7function assertJsonSchema(schema: Schema.Constraint, expected: JsonSchema.JsonSchema) {
8 assert.deepStrictEqual(toCodecAnthropic(schema).jsonSchema, expected)
9}
10
11function assertError(schema: Schema.Constraint, message: string) {
12 assert.throws(() => toCodecAnthropic(schema), message)
13}
14
15describe("toCodecAnthropic", () => {
16 describe("Canonical JSON and mechanical invariants", () => {
17 it("encodes Undefined as null", () => {
18 assertJsonSchema(Schema.Undefined, { type: "null" })
19 })
20
21 it("encodes a bigint Literal as a string", () => {
22 assertJsonSchema(Schema.Literal(1n), { type: "string", enum: ["1"] })
23 })
24
25 describe("Arrays", () => {
26 it("encodes post-rest elements as object properties", () => {
27 assertJsonSchema(
28 Schema.TupleWithRest(Schema.Tuple([]), [Schema.String, Schema.String]),
29 {
30 type: "object",
31 properties: {
32 __rest__: { type: "array", items: { type: "string" } },
33 __tail_0__: { type: "string" }
34 },
35 required: ["__rest__", "__tail_0__"],
36 additionalProperties: false,
37 description:
38 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements. Post-rest elements use '__tail_0__', '__tail_1__', and so on"
39 }
40 )
41 })
42 })
43
44 describe("Objects", () => {
45 it("non-string property signature name", () => {
46 assertError(
47 Schema.Struct({ [Symbol.for("effect/Schema/test/a")]: Schema.String }),
48 "Objects property names must be strings"
49 )
50 })
51 })
52
53 it("non-recursive Suspend", () => {
54 assertJsonSchema(Schema.suspend(() => Schema.String), { type: "string" })
55 })
56
57 it("rejects recursive Suspend", () => {
58 interface A {
59 readonly a: string
60 readonly as: ReadonlyArray<A>
61 }
62 const schema = Schema.Struct({
63 a: Schema.String.check(Schema.isStartingWith("a")),
64 as: Schema.Array(Schema.suspend((): Schema.Codec<A> => schema))
65 })
66 assertError(schema, "AnthropicStructuredOutput: Recursive schemas are not supported")
67 })
68 })
69
70 it("Null", () => {
71 assertJsonSchema(Schema.Null, {
72 "type": "null"
73 })
74 })
75
76 describe("String", () => {
77 it("String", () => {
78 assertJsonSchema(Schema.String, {
79 "type": "string"
80 })
81 assertJsonSchema(Schema.String.annotate({ description: "description" }), {
82 "type": "string",
83 "description": "description"
84 })
85 })
86
87 it("String + supported format", () => {
88 assertJsonSchema(Schema.String.annotate({ format: "date-time" }), {
89 "type": "string",
90 "format": "date-time"
91 })
92 })
93
94 it("String + unsupported format", () => {
95 assertJsonSchema(Schema.String.annotate({ format: "int32" }), {
96 "type": "string",
97 "description": "a value with a format of int32"
98 })
99 })
100
101 it("String + isMinLength", () => {
102 assertJsonSchema(Schema.String.check(Schema.isMinLength(1)), {
103 "type": "string",
104 "description": "a value with a length of at least 1"
105 })
106 })
107
108 it("String + isMinLength + isMaxLength", () => {
109 assertJsonSchema(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(2)), {
110 "type": "string",
111 "description": "a value with a length of at least 1 and a value with a length of at most 2"
112 })
113 })
114
115 it("String + startsWith", () => {
116 assertJsonSchema(Schema.String.check(Schema.isStartingWith("a")), {
117 "type": "string",
118 "description": `a string starting with "a"`,
119 "pattern": "^a"
120 })
121 })
122
123 it("String + startsWith + endsWith", () => {
124 assertJsonSchema(Schema.String.check(Schema.isStartingWith("a"), Schema.isEndingWith("b")), {
125 "type": "string",
126 "description": `a string starting with "a" and a string ending with "b"`,
127 "pattern": "^a",
128 "allOf": [
129 { "pattern": "b$" }
130 ]
131 })
132 })
133
134 it("String + pattern", () => {
135 assertJsonSchema(Schema.String.check(Schema.isPattern(/^a/u)), {
136 "type": "string",
137 "description": `a string matching the RegExp ^a`,
138 "pattern": "^a"
139 })
140 })
141
142 it("String + pattern + description", () => {
143 assertJsonSchema(Schema.String.check(Schema.isPattern(/^a/u, { description: "description" })), {
144 "type": "string",
145 "description": "description",
146 "pattern": "^a"
147 })
148 })
149 })
150
151 describe("Number", () => {
152 it("Number", () => {
153 assertJsonSchema(Schema.Number, {
154 "anyOf": [
155 { "type": "number" },
156 { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] }
157 ]
158 })
159 })
160
161 describe("Finite", () => {
162 it("Finite", () => {
163 assertJsonSchema(Schema.Finite, {
164 "type": "number"
165 })
166 assertJsonSchema(Schema.Finite.annotate({ description: "description" }), {
167 "type": "number",
168 "description": "description"
169 })
170 })
171
172 it("Finite + string format", () => {
173 assertJsonSchema(Schema.Finite.annotate({ format: "duration" }), {
174 "type": "number",
175 "description": "a value with a format of duration"
176 })
177 })
178
179 it("Finite + unsupported format", () => {
180 assertJsonSchema(Schema.Finite.annotate({ format: "int32" }), {
181 "type": "number",
182 "description": "a value with a format of int32"
183 })
184 })
185
186 it("Finite + isGreaterThan", () => {
187 assertJsonSchema(Schema.Finite.check(Schema.isGreaterThan(1)), {
188 "type": "number",
189 "description": "a value greater than 1"
190 })
191 })
192
193 it("Finite + isGreaterThan + isLessThan", () => {
194 assertJsonSchema(Schema.Finite.check(Schema.isGreaterThan(1), Schema.isLessThan(2)), {
195 "type": "number",
196 "description": "a value greater than 1 and a value less than 2"
197 })
198 })
199 })
200
201 describe("Int", () => {
202 it("Int", () => {
203 assertJsonSchema(Schema.Int, {
204 "type": "integer"
205 })
206 assertJsonSchema(Schema.Int.annotate({ description: "description" }), {
207 "type": "integer",
208 "description": "description"
209 })
210 })
211
212 it("Int + string format", () => {
213 assertJsonSchema(Schema.Int.annotate({ format: "duration" }), {
214 "type": "integer",
215 "description": "a value with a format of duration"
216 })
217 })
218
219 it("Int + unsupported format", () => {
220 assertJsonSchema(Schema.Int.annotate({ format: "int32" }), {
221 "type": "integer",
222 "description": "a value with a format of int32"
223 })
224 })
225
226 it("Int + isGreaterThan", () => {
227 assertJsonSchema(Schema.Int.check(Schema.isGreaterThan(1)), {
228 "type": "integer",
229 "description": "a value greater than 1"
230 })
231 })
232
233 it("Int + isGreaterThan + isLessThan", () => {
234 assertJsonSchema(Schema.Int.check(Schema.isGreaterThan(1), Schema.isLessThan(2)), {
235 "type": "integer",
236 "description": "a value greater than 1 and a value less than 2"
237 })
238 })
239 })
240 })
241
242 describe("Literal", () => {
243 it("Literal(string)", () => {
244 assertJsonSchema(Schema.Literal("a"), {
245 "type": "string",
246 "enum": ["a"]
247 })
248 })
249
250 it("Literal(number)", () => {
251 assertJsonSchema(Schema.Literal(1), {
252 "type": "number",
253 "enum": [1]
254 })
255 })
256
257 it("Literal(boolean)", () => {
258 assertJsonSchema(Schema.Literal(true), {
259 "type": "boolean",
260 "enum": [true]
261 })
262 })
263 })
264
265 describe("Union", () => {
266 it("oneOf", () => {
267 assertJsonSchema(Schema.Union([Schema.String, Schema.Finite], { mode: "oneOf" }), {
268 "anyOf": [
269 { "type": "string" },
270 { "type": "number" }
271 ]
272 })
273 })
274
275 it("Union(NonEmptyString, Int)", () => {
276 assertJsonSchema(Schema.Union([Schema.NonEmptyString, Schema.Int]), {
277 "anyOf": [
278 { "type": "string", "description": "a value with a length of at least 1" },
279 { "type": "integer" }
280 ]
281 })
282 })
283 })
284
285 describe("Array", () => {
286 it("Array(String)", () => {
287 assertJsonSchema(Schema.Array(Schema.String), {
288 "type": "array",
289 "items": { "type": "string" }
290 })
291 assertJsonSchema(Schema.Array(Schema.String).annotate({ description: "description" }), {
292 "type": "array",
293 "items": { "type": "string" },
294 "description": "description"
295 })
296 })
297
298 it("Array(String) + isMinLength", () => {
299 assertJsonSchema(Schema.Array(Schema.String).check(Schema.isMinLength(1)), {
300 "type": "array",
301 "items": { "type": "string" },
302 "description": "a value with a length of at least 1"
303 })
304 })
305
306 it("Array(String) + isMinLength + isMaxLength", () => {
307 assertJsonSchema(Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(2)), {
308 "type": "array",
309 "items": { "type": "string" },
310 "description": "a value with a length of at least 1 and a value with a length of at most 2"
311 })
312 })
313 })
314
315 describe("Tuple", () => {
316 it("Tuple([])", () => {
317 assertJsonSchema(Schema.Tuple([]), {
318 "type": "array",
319 "items": false
320 })
321 })
322
323 it("Tuple([String, Finite])", async () => {
324 const schema = Schema.Tuple([Schema.String, Schema.Finite])
325 assertJsonSchema(schema, {
326 "type": "object",
327 "description":
328 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
329 "properties": {
330 "0": { "type": "string" },
331 "1": { "type": "number" }
332 },
333 "required": ["0", "1"],
334 "additionalProperties": false
335 })
336
337 const codec = toCodecAnthropic(schema).codec
338 const asserts = new TestSchema.Asserts(codec)
339
340 const encoding = asserts.encoding()
341 await encoding.succeed(["a", 1], { "0": "a", "1": 1 })
342
343 const decoding = asserts.decoding()
344 await decoding.succeed({ "0": "a", "1": 1 }, ["a", 1])
345 })
346
347 it("Tuple([String, Finite]) + description", () => {
348 const schema = Schema.Tuple([Schema.String, Schema.Finite]).annotate({ description: "description" })
349 assertJsonSchema(schema, {
350 "type": "object",
351 "description":
352 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements; description",
353 "properties": {
354 "0": { "type": "string" },
355 "1": { "type": "number" }
356 },
357 "required": ["0", "1"],
358 "additionalProperties": false
359 })
360 })
361
362 it("Tuple([String, Finite?])", async () => {
363 const schema = Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Finite)])
364 assertJsonSchema(schema, {
365 "type": "object",
366 "description":
367 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
368 "properties": {
369 "0": { "type": "string" },
370 "1": {
371 "anyOf": [
372 { "type": "number" },
373 { "type": "null" }
374 ]
375 }
376 },
377 "required": ["0", "1"],
378 "additionalProperties": false
379 })
380
381 const codec = toCodecAnthropic(schema).codec
382 const asserts = new TestSchema.Asserts(codec)
383
384 const encoding = asserts.encoding()
385 await encoding.succeed(["a", 1], { "0": "a", "1": 1 })
386 await encoding.succeed(["a"], { "0": "a", "1": null })
387
388 const decoding = asserts.decoding()
389 await decoding.succeed({ "0": "a", "1": 1 }, ["a", 1])
390 await decoding.succeed({ "0": "a", "1": null }, ["a"])
391 })
392 })
393
394 describe("TupleWithRest", () => {
395 it("TupleWithRest([String, Finite], Boolean)", async () => {
396 const schema = Schema.TupleWithRest(
397 Schema.Tuple([Schema.String, Schema.Finite]),
398 [Schema.Boolean]
399 )
400 assertJsonSchema(schema, {
401 "type": "object",
402 "description":
403 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
404 "properties": {
405 "0": { "type": "string" },
406 "1": { "type": "number" },
407 "__rest__": { "type": "array", "items": { "type": "boolean" } }
408 },
409 "required": ["0", "1", "__rest__"],
410 "additionalProperties": false
411 })
412 const codec = toCodecAnthropic(schema).codec
413 const asserts = new TestSchema.Asserts(codec)
414
415 const encoding = asserts.encoding()
416 await encoding.succeed(["a", 1], { "0": "a", "1": 1, "__rest__": [] })
417 await encoding.succeed(["a", 1, true], { "0": "a", "1": 1, "__rest__": [true] })
418 await encoding.succeed(["a", 1, true, false], { "0": "a", "1": 1, "__rest__": [true, false] })
419
420 const decoding = asserts.decoding()
421 await decoding.succeed({ "0": "a", "1": 1, "__rest__": [] }, ["a", 1])
422 await decoding.succeed({ "0": "a", "1": 1, "__rest__": [true] }, ["a", 1, true])
423 await decoding.succeed({ "0": "a", "1": 1, "__rest__": [true, false] }, ["a", 1, true, false])
424 })
425 })
426
427 describe("Struct", () => {
428 it("required properties", () => {
429 assertJsonSchema(Schema.Struct({ a: Schema.String }), {
430 "type": "object",
431 "properties": {
432 "a": { "type": "string" }
433 },
434 "required": ["a"],
435 "additionalProperties": false
436 })
437 })
438
439 it("optional properties", async () => {
440 const schema = Schema.Struct({
441 a: Schema.String,
442 b: Schema.optionalKey(Schema.Finite)
443 })
444 assertJsonSchema(
445 schema,
446 {
447 "type": "object",
448 "properties": {
449 "a": { "type": "string" },
450 "b": {
451 "anyOf": [
452 { "type": "number" },
453 { "type": "null" }
454 ]
455 }
456 },
457 "required": ["a", "b"],
458 "additionalProperties": false
459 }
460 )
461 const codec = toCodecAnthropic(schema).codec
462 const asserts = new TestSchema.Asserts(codec)
463
464 const encoding = asserts.encoding()
465 await encoding.succeed({ a: "a", b: 1 })
466 await encoding.succeed({ a: "a" }, { a: "a", b: null })
467
468 const decoding = asserts.decoding()
469 await decoding.succeed({ "a": "a", "b": 1 })
470 await decoding.succeed({ "a": "a", "b": null }, { a: "a" })
471 })
472 })
473
474 it("Class", () => {
475 class Person extends Schema.Class<Person>("Person")({
476 name: Schema.String
477 }) {}
478
479 assertJsonSchema(Person, {
480 "type": "object",
481 "properties": {
482 "name": { "type": "string" }
483 },
484 "required": ["name"],
485 "additionalProperties": false,
486 "$defs": {
487 "PersonEncoded": {
488 "type": "object",
489 "properties": {
490 "name": { "type": "string" }
491 },
492 "required": ["name"],
493 "additionalProperties": false
494 }
495 }
496 })
497 })
498
499 describe("Record", () => {
500 it("EmptyParams", () => {
501 assertJsonSchema(Tool.EmptyParams, {
502 "type": "object",
503 "additionalProperties": false
504 })
505 assertJsonSchema(Schema.Record(Schema.String, Schema.Never), {
506 "type": "object",
507 "additionalProperties": false
508 })
509 })
510
511 it("Record(String, Finite)", async () => {
512 const schema = Schema.Record(Schema.String, Schema.Finite)
513 assertJsonSchema(schema, {
514 "type": "array",
515 "description": "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object",
516 "items": {
517 "type": "object",
518 "description":
519 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
520 "properties": {
521 "0": { "type": "string" },
522 "1": { "type": "number" }
523 },
524 "required": ["0", "1"],
525 "additionalProperties": false
526 }
527 })
528 const codec = toCodecAnthropic(schema).codec
529 const asserts = new TestSchema.Asserts(codec)
530
531 const encoding = asserts.encoding()
532 await encoding.succeed({ "a": 1, "b": 2 }, [{ 0: "a", 1: 1 }, { 0: "b", 1: 2 }])
533
534 const decoding = asserts.decoding()
535 await decoding.succeed([{ 0: "a", 1: 1 }, { 0: "b", 1: 2 }], { "a": 1, "b": 2 })
536 })
537
538 it("Record with properties and an index signature", async () => {
539 const schema = Schema.Record(
540 Schema.Union([Schema.Literal("fixed"), Schema.String]),
541 Schema.String
542 )
543 const result = toCodecAnthropic(schema)
544 assert.deepStrictEqual(result.jsonSchema, {
545 type: "array",
546 description: "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object",
547 items: {
548 type: "object",
549 description:
550 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
551 properties: {
552 "0": {
553 anyOf: [
554 { type: "string", enum: ["fixed"] },
555 { type: "string" }
556 ]
557 },
558 "1": { type: "string" }
559 },
560 required: ["0", "1"],
561 additionalProperties: false
562 }
563 })
564
565 const asserts = new TestSchema.Asserts(result.codec)
566 await asserts.encoding().succeed(
567 { fixed: "required", dynamic: "value" },
568 [{ 0: "fixed", 1: "required" }, { 0: "dynamic", 1: "value" }]
569 )
570 await asserts.decoding().succeed(
571 [{ 0: "fixed", 1: "required" }, { 0: "dynamic", 1: "value" }],
572 { fixed: "required", dynamic: "value" }
573 )
574 assert.strictEqual(
575 Schema.decodeUnknownExit(result.codec)([{ 0: "dynamic", 1: "value" }])._tag,
576 "Failure"
577 )
578 })
579
580 it("Record(String, Finite) + description", () => {
581 const schema = Schema.Record(Schema.String, Schema.Finite).annotate({ description: "description" })
582 assertJsonSchema(schema, {
583 "type": "array",
584 "description":
585 "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object; description",
586 "items": {
587 "type": "object",
588 "description":
589 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
590 "properties": {
591 "0": { "type": "string" },
592 "1": { "type": "number" }
593 },
594 "required": ["0", "1"],
595 "additionalProperties": false
596 }
597 })
598 })
599
600 it("Record(String, Finite) + isMinProperties", async () => {
601 const schema = Schema.Record(Schema.String, Schema.Finite).check(Schema.isMinProperties(2))
602 const result = toCodecAnthropic(schema)
603 assert.deepStrictEqual(result.jsonSchema, {
604 "type": "array",
605 "description":
606 "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object; a value with at least 2 entries",
607 "items": {
608 "type": "object",
609 "description":
610 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
611 "properties": {
612 "0": { "type": "string" },
613 "1": { "type": "number" }
614 },
615 "required": ["0", "1"],
616 "additionalProperties": false
617 }
618 })
619 await new TestSchema.Asserts(result.codec).decoding().fail(
620 [{ 0: "a", 1: 1 }, { 0: "a", 1: 2 }],
621 `Expected a value with at least 2 entries`
622 )
623 })
624
625 it("Record(String, Finite) + isMinProperties + description", () => {
626 const schema = Schema.Record(Schema.String, Schema.Finite).check(
627 Schema.isMinProperties(2, { description: "description" })
628 )
629 assertJsonSchema(schema, {
630 "type": "array",
631 "description":
632 "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object; description",
633 "items": {
634 "type": "object",
635 "description":
636 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements",
637 "properties": {
638 "0": { "type": "string" },
639 "1": { "type": "number" }
640 },
641 "required": ["0", "1"],
642 "additionalProperties": false
643 }
644 })
645 })
646 })
647})