1/**
2 * Adapts Effect schemas for OpenAI structured output.
3 *
4 * OpenAI structured output accepts only a subset of JSON Schema. This module
5 * converts an Effect `Schema.Codec` into a provider-compatible JSON Schema and
6 * a matching codec for decoding the model response back into the original
7 * application type. Unsupported constraints can be omitted from the provider
8 * schema and remain 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"
17
18/**
19 * Converts a `Schema.Codec` to OpenAI structured-output JSON Schema and a
20 * matching codec for model output.
21 *
22 * **When to use**
23 *
24 * Use when you send Effect Schema-backed structured output requests to OpenAI
25 * standard models and need provider-compatible JSON Schema without losing the
26 * decoded application type.
27 *
28 * **Details**
29 *
30 * Returns the JSON Schema to include in the request and the codec to use when
31 * decoding the model response. The codec remains authoritative: the provider
32 * JSON Schema can be a lossy, less restrictive representation when OpenAI
33 * cannot express an Effect Schema constraint. Conversion throws when the
34 * resulting root is not an object or contains `anyOf`.
35 *
36 * **Gotchas**
37 *
38 * - Some schemas use a provider-safe encoded shape: tuples become objects with
39 * numeric string keys, objects with index signatures become arrays of
40 * `[key, value]` pairs, and optional properties become required nullable
41 * properties.
42 * - `oneOf` unions are emitted as `anyOf` unions.
43 * - Compatible regex patterns are merged because OpenAI structured output does
44 * not support `allOf`.
45 * - The root JSON Schema must be an object and cannot use `anyOf`.
46 * - Constraints inside `allOf` are retained only when they have an explicit,
47 * semantics-preserving normalization rule.
48 * - Structural constraints inside `allOf`, such as `properties`, `required`,
49 * `additionalProperties`, and `items`, are omitted instead of being merged
50 * with a different meaning.
51 * - Unsupported constraints are removed from the provider schema and are still
52 * checked while decoding with the returned codec.
53 * - Compatibility targets standard OpenAI models. Fine-tuned models support a
54 * smaller JSON Schema subset.
55 *
56 * @stability unstable
57 * @category transforming
58 * @since 4.0.0
59 */
60export function toCodecOpenAI<T, E, RD, RE>(
61 schema: Schema.ConstraintCodec<T, E, RD, RE>
62): {
63 codec: Schema.ConstraintCodec<T, unknown, RD, RE>
64 jsonSchema: JsonSchema.JsonSchema
65} {
66 const codec = InternalStructuredOutput.toCodec(schema)
67 const document = InternalStructuredOutput.resolveTopLevelReference(
68 Schema.toJsonSchemaDocument(codec, {
69 generateDescriptions: true,
70 onExcessProperty: "error"
71 })
72 )
73 const jsonSchema = rewriteOpenAI(document.schema)
74 if (jsonSchema.type !== "object" || jsonSchema.anyOf !== undefined) {
75 throw new Error(
76 `OpenAiStructuredOutput: Root JSON Schema must have type "object" and must not use "anyOf"`
77 )
78 }
79 if (Object.keys(document.definitions).length > 0) {
80 jsonSchema.$defs = Rec.map(document.definitions, rewriteOpenAI)
81 }
82 return { codec, jsonSchema }
83}
84
85function rewriteOpenAI(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema {
86 return InternalStructuredOutput.walkJsonSchema(schema, (schema) => {
87 const normalized = normalizeAllOf(schema)
88 const out: JsonSchema.JsonSchema = {}
89 let unsupportedFormat: string | undefined
90 for (const [key, value] of Object.entries(normalized)) {
91 if (key === "format") {
92 if (typeof value === "string" && formats.has(value) && supportsType(key, normalized.type)) out.format = value
93 else if (typeof value === "string") unsupportedFormat = value
94 } else if (supportedKeywords.has(key) && supportsType(key, normalized.type)) {
95 if (key !== "additionalProperties" || value === false) out[key] = value
96 }
97 }
98 if (unsupportedFormat !== undefined) {
99 InternalStructuredOutput.appendDescription(out, `a value with a format of ${unsupportedFormat}`)
100 }
101 if (out.type === "object" && out.properties === undefined && out.additionalProperties === false) {
102 out.properties = {}
103 }
104 return out
105 })
106}
107
108function supportsType(key: string, type: unknown): boolean {
109 if (typeof type !== "string") return true
110 switch (key) {
111 case "pattern":
112 case "format":
113 return type === "string"
114 case "multipleOf":
115 case "minimum":
116 case "exclusiveMinimum":
117 case "maximum":
118 case "exclusiveMaximum":
119 return type === "number" || type === "integer"
120 case "items":
121 case "minItems":
122 case "maxItems":
123 return type === "array"
124 case "properties":
125 case "patternProperties":
126 case "required":
127 case "additionalProperties":
128 return type === "object"
129 default:
130 return true
131 }
132}
133
134function normalizeAllOf(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema {
135 if (!Array.isArray(schema.allOf)) return schema
136
137 const out: JsonSchema.JsonSchema = {}
138 const patterns: Array<string> = []
139 const baseKeywords = new Set<string>()
140 const memberKeywords = new Set<string>()
141 const ambiguousKeywords = new Set<string>()
142 for (const [key, value] of Object.entries(schema)) {
143 if (key === "allOf") continue
144 baseKeywords.add(key)
145 if (key === "pattern" && typeof value === "string") patterns.push(value)
146 else out[key] = value
147 }
148 for (const member of schema.allOf) {
149 if (!InternalStructuredOutput.isJsonSchema(member)) continue
150 for (const [key, value] of Object.entries(member)) {
151 mergeAllOfKeyword(out, key, value, patterns, baseKeywords, memberKeywords, ambiguousKeywords)
152 }
153 }
154 const uniquePatterns = Array.from(new Set(patterns))
155 if (uniquePatterns.length === 1) {
156 out.pattern = uniquePatterns[0]
157 } else if (uniquePatterns.length > 1) {
158 const combined = uniquePatterns.map((source) => `(?=[\\s\\S]*?(?:${source}))`).join("")
159 out.pattern = `^${combined}`
160 }
161 return out
162}
163
164function mergeAllOfKeyword(
165 out: JsonSchema.JsonSchema,
166 key: string,
167 value: unknown,
168 patterns: Array<string>,
169 baseKeywords: ReadonlySet<string>,
170 memberKeywords: Set<string>,
171 ambiguousKeywords: Set<string>
172): void {
173 switch (key) {
174 case "description":
175 if (typeof value === "string") InternalStructuredOutput.appendDescription(out, value)
176 return
177 case "pattern":
178 if (typeof value === "string") patterns.push(value)
179 return
180 case "minimum":
181 case "exclusiveMinimum":
182 mergeLowerBound(out, key, value)
183 return
184 case "maximum":
185 case "exclusiveMaximum":
186 mergeUpperBound(out, key, value)
187 return
188 case "minItems":
189 mergeMinimum(out, key, value)
190 return
191 case "maxItems":
192 mergeMaximum(out, key, value)
193 return
194 default:
195 if (!normalizableAllOfKeywords.has(key) || baseKeywords.has(key) || ambiguousKeywords.has(key)) return
196 if (!memberKeywords.has(key)) {
197 out[key] = value
198 memberKeywords.add(key)
199 } else if (out[key] !== value) {
200 delete out[key]
201 memberKeywords.delete(key)
202 ambiguousKeywords.add(key)
203 }
204 return
205 }
206}
207
208function mergeMinimum(schema: JsonSchema.JsonSchema, key: string, value: unknown): void {
209 if (typeof value !== "number") return
210 const current = schema[key]
211 if (typeof current !== "number" || value > current) schema[key] = value
212}
213
214function mergeMaximum(schema: JsonSchema.JsonSchema, key: string, value: unknown): void {
215 if (typeof value !== "number") return
216 const current = schema[key]
217 if (typeof current !== "number" || value < current) schema[key] = value
218}
219
220function mergeLowerBound(schema: JsonSchema.JsonSchema, key: string, value: unknown): void {
221 if (typeof value !== "number") {
222 if (!Object.hasOwn(schema, key)) schema[key] = value
223 return
224 }
225 const minimum = typeof schema.minimum === "number" ? schema.minimum : undefined
226 const exclusiveMinimum = typeof schema.exclusiveMinimum === "number" ? schema.exclusiveMinimum : undefined
227 let current: { readonly value: number; readonly exclusive: boolean } | undefined
228 if (minimum !== undefined) current = { value: minimum, exclusive: false }
229 if (
230 exclusiveMinimum !== undefined &&
231 (current === undefined || exclusiveMinimum >= current.value)
232 ) {
233 current = { value: exclusiveMinimum, exclusive: true }
234 }
235 const candidate = { value, exclusive: key === "exclusiveMinimum" }
236 if (
237 current === undefined ||
238 candidate.value > current.value ||
239 (candidate.value === current.value && candidate.exclusive)
240 ) {
241 current = candidate
242 }
243 delete schema.minimum
244 delete schema.exclusiveMinimum
245 schema[current.exclusive ? "exclusiveMinimum" : "minimum"] = current.value
246}
247
248function mergeUpperBound(schema: JsonSchema.JsonSchema, key: string, value: unknown): void {
249 if (typeof value !== "number") {
250 if (!Object.hasOwn(schema, key)) schema[key] = value
251 return
252 }
253 const maximum = typeof schema.maximum === "number" ? schema.maximum : undefined
254 const exclusiveMaximum = typeof schema.exclusiveMaximum === "number" ? schema.exclusiveMaximum : undefined
255 let current: { readonly value: number; readonly exclusive: boolean } | undefined
256 if (maximum !== undefined) current = { value: maximum, exclusive: false }
257 if (
258 exclusiveMaximum !== undefined &&
259 (current === undefined || exclusiveMaximum <= current.value)
260 ) {
261 current = { value: exclusiveMaximum, exclusive: true }
262 }
263 const candidate = { value, exclusive: key === "exclusiveMaximum" }
264 if (
265 current === undefined ||
266 candidate.value < current.value ||
267 (candidate.value === current.value && candidate.exclusive)
268 ) {
269 current = candidate
270 }
271 delete schema.maximum
272 delete schema.exclusiveMaximum
273 schema[current.exclusive ? "exclusiveMaximum" : "maximum"] = current.value
274}
275
276const supportedKeywords = new Set([
277 "$ref",
278 "type",
279 "title",
280 "description",
281 "enum",
282 "anyOf",
283 "properties",
284 "required",
285 "additionalProperties",
286 "items",
287 "pattern",
288 "multipleOf",
289 "minimum",
290 "exclusiveMinimum",
291 "maximum",
292 "exclusiveMaximum",
293 "minItems",
294 "maxItems"
295])
296
297const normalizableAllOfKeywords = new Set([
298 "$ref",
299 "title",
300 "enum",
301 "anyOf",
302 "format",
303 "multipleOf"
304])
305
306const formats = new Set([
307 "date-time",
308 "time",
309 "date",
310 "duration",
311 "email",
312 "hostname",
313 "ipv4",
314 "ipv6",
315 "uuid"
316])