1/**
2 * Adapts Effect Schema codecs to the JSON Schema subset accepted by Anthropic
3 * structured output.
4 *
5 * The main entry point returns the JSON Schema to send to Anthropic and a codec
6 * for decoding the model response back into the original application type.
7 * Unsupported constraints can be omitted from the provider schema and remain
8 * enforced by the returned codec.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import type * as JsonSchema from "../JsonSchema.ts"
14import * as Rec from "../Record.ts"
15import * as Schema from "../Schema.ts"
16import * as InternalStructuredOutput from "./internal/structured-output.ts"
17import * as LanguageModel from "./LanguageModel.ts"
18import * as OpenAiStructuredOutput from "./OpenAiStructuredOutput.ts"
19
20/**
21 * Converts a `Schema.Codec` to Anthropic structured-output JSON Schema and a
22 * matching codec for model output.
23 *
24 * **When to use**
25 *
26 * Use when you send Effect Schema-backed structured output requests to
27 * Anthropic and need provider-compatible JSON Schema without losing the decoded
28 * application type.
29 *
30 * **Details**
31 *
32 * Returns the JSON Schema to include in the request and the codec to use when
33 * decoding the model response. The codec remains authoritative: the provider
34 * JSON Schema can be a lossy, less restrictive representation when Anthropic
35 * cannot express an Effect Schema constraint.
36 *
37 * **Gotchas**
38 *
39 * - Some schemas use a provider-safe encoded shape: tuples become objects with
40 * numeric string keys, objects with index signatures become arrays of
41 * `[key, value]` pairs, and optional properties become required nullable
42 * properties.
43 * - `oneOf` unions are emitted as `anyOf` unions.
44 * - Unsupported constraints are removed from the provider schema and are still
45 * checked while decoding with the returned codec.
46 * - Recursive schemas throw during conversion because Anthropic structured
47 * output does not support recursive references.
48 *
49 * @see {@link LanguageModel.CodecTransformer} for the structured-output transformer contract
50 * @see {@link OpenAiStructuredOutput.toCodecOpenAI} for the OpenAI-specific transformer
51 *
52 * @stability unstable
53 * @category transforming
54 * @since 4.0.0
55 */
56export function toCodecAnthropic<T, E, RD, RE>(
57 schema: Schema.ConstraintCodec<T, E, RD, RE>
58): {
59 readonly codec: Schema.ConstraintCodec<T, unknown, RD, RE>
60 readonly jsonSchema: JsonSchema.JsonSchema
61} {
62 const codec = InternalStructuredOutput.toCodec(schema)
63 const unresolvedDocument = Schema.toJsonSchemaDocument(codec, {
64 generateDescriptions: true,
65 onExcessProperty: "error"
66 })
67 if (hasReferenceCycle(unresolvedDocument.schema, unresolvedDocument.definitions)) {
68 throw new Error("AnthropicStructuredOutput: Recursive schemas are not supported")
69 }
70 const document = InternalStructuredOutput.resolveTopLevelReference(unresolvedDocument)
71 const jsonSchema = rewriteAnthropic(document.schema)
72 if (Object.keys(document.definitions).length > 0) {
73 jsonSchema.$defs = Rec.map(document.definitions, rewriteAnthropic)
74 }
75 return { codec, jsonSchema }
76}
77
78function hasReferenceCycle(
79 root: JsonSchema.JsonSchema,
80 definitions: JsonSchema.Definitions
81): boolean {
82 const visiting = new Set<JsonSchema.JsonSchema>()
83 const visited = new Set<JsonSchema.JsonSchema>()
84
85 function visit(schema: JsonSchema.JsonSchema): boolean {
86 if (visiting.has(schema)) return true
87 if (visited.has(schema)) return false
88
89 visiting.add(schema)
90 let cycle = false
91 InternalStructuredOutput.walkJsonSchema(schema, (node) => {
92 if (!cycle && typeof node.$ref === "string") {
93 const target = node.$ref === "#" ? root : InternalStructuredOutput.resolveReference(node.$ref, definitions)
94 if (visit(target)) cycle = true
95 }
96 return node
97 })
98 visiting.delete(schema)
99 visited.add(schema)
100 return cycle
101 }
102
103 return visit(root)
104}
105
106function rewriteAnthropic(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema {
107 return InternalStructuredOutput.walkJsonSchema(schema, (schema) => {
108 const normalized = hoistAllOfDescriptions(schema)
109 const out: JsonSchema.JsonSchema = {}
110 let unsupportedFormat: string | undefined
111 for (const [key, value] of Object.entries(normalized)) {
112 if (key === "format") {
113 if (
114 typeof value === "string" && formats.has(value) &&
115 (normalized.type === undefined || normalized.type === "string")
116 ) {
117 out.format = value
118 } else if (typeof value === "string") unsupportedFormat = value
119 } else if (supportedKeywords.has(key)) {
120 if (key !== "additionalProperties" || value === false) out[key] = value
121 }
122 }
123 if (unsupportedFormat !== undefined) {
124 InternalStructuredOutput.appendDescription(out, `a value with a format of ${unsupportedFormat}`)
125 }
126 return out
127 })
128}
129
130function hoistAllOfDescriptions(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema {
131 if (!Array.isArray(schema.allOf)) return schema
132
133 const out: JsonSchema.JsonSchema = {}
134 for (const [key, value] of Object.entries(schema)) {
135 if (key !== "allOf") out[key] = value
136 }
137 const members: Array<JsonSchema.JsonSchema> = []
138 for (const member of schema.allOf) {
139 if (!InternalStructuredOutput.isJsonSchema(member)) continue
140 const { description, format, title, ...memberRest } = member
141 const rest = { ...memberRest }
142 if (schema.type !== undefined && schema.type !== "string") delete rest.pattern
143 if (typeof description === "string") {
144 InternalStructuredOutput.appendDescription(out, description)
145 }
146 if (out.format === undefined && typeof format === "string") out.format = format
147 if (out.title === undefined && typeof title === "string") out.title = title
148 if (Object.keys(rest).length > 0) members.push(rest)
149 }
150 if (members.length > 0) out.allOf = members
151 return out
152}
153
154const supportedKeywords = new Set([
155 "$ref",
156 "type",
157 "title",
158 "description",
159 "enum",
160 "const",
161 "anyOf",
162 "allOf",
163 "properties",
164 "required",
165 "additionalProperties",
166 "items",
167 "pattern"
168])
169
170const formats = new Set([
171 "date-time",
172 "time",
173 "date",
174 "duration",
175 "email",
176 "hostname",
177 "uri",
178 "ipv4",
179 "ipv6",
180 "uuid"
181])