This repository was archived by the owner on May 15, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathjson-schema.ts
More file actions
254 lines (235 loc) · 9.21 KB
/
Copy pathjson-schema.ts
File metadata and controls
254 lines (235 loc) · 9.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import type { z as z4 } from "zod/v4"
import { z } from "zod"
/**
* Re-export Zod v4's JSONSchema type for convenience
*/
export type JsonSchema = z4.core.JSONSchema.JSONSchema
/**
* Set of format values supported by OpenAI's Structured Outputs (strict mode).
* Unsupported format values will be stripped during schema normalization.
* @see https://platform.openai.com/docs/guides/structured-outputs#supported-schemas
*/
const OPENAI_SUPPORTED_FORMATS = new Set([
"date-time",
"time",
"date",
"duration",
"email",
"hostname",
"ipv4",
"ipv6",
"uuid",
])
/**
* Array-specific JSON Schema properties that must be nested inside array type variants
* when converting to anyOf format (JSON Schema draft 2020-12).
*/
const ARRAY_SPECIFIC_PROPERTIES = ["items", "minItems", "maxItems", "uniqueItems"] as const
/**
* Applies array-specific properties from source to target object.
* Only copies properties that are defined in the source.
*/
function applyArrayProperties(
target: Record<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
for (const prop of ARRAY_SPECIFIC_PROPERTIES) {
if (source[prop] !== undefined) {
target[prop] = source[prop]
}
}
return target
}
/**
* Zod schema for JSON Schema primitive types
*/
const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"])
/**
* All valid JSON Schema type values including object and array
*/
const JsonSchemaTypeSchema = z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")])
/**
* Zod schema for JSON Schema enum values
*/
const JsonSchemaEnumValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
/**
* Zod schema that validates tool input JSON Schema and sets `additionalProperties: false` by default.
* Uses recursive parsing so the default applies to all nested schemas automatically.
*
* This is required by some API providers (e.g., OpenAI) for strict function calling.
*
* @example
* ```typescript
* // Validates and applies defaults in one pass - throws on invalid
* const validatedSchema = ToolInputSchema.parse(schema)
*
* // Or use safeParse for error handling
* const result = ToolInputSchema.safeParse(schema)
* if (result.success) {
* // result.data has additionalProperties: false by default
* }
* ```
*/
export const ToolInputSchema: z.ZodType<JsonSchema> = z.lazy(() =>
z
.object({
type: JsonSchemaTypeSchema.optional(),
properties: z.record(z.string(), ToolInputSchema).optional(),
items: z.union([ToolInputSchema, z.array(ToolInputSchema)]).optional(),
required: z.array(z.string()).optional(),
additionalProperties: z.union([z.boolean(), ToolInputSchema]).default(false),
description: z.string().optional(),
default: z.unknown().optional(),
enum: z.array(JsonSchemaEnumValueSchema).optional(),
const: JsonSchemaEnumValueSchema.optional(),
anyOf: z.array(ToolInputSchema).optional(),
oneOf: z.array(ToolInputSchema).optional(),
allOf: z.array(ToolInputSchema).optional(),
$ref: z.string().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
pattern: z.string().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
})
.passthrough(),
)
/**
* Schema for type field that accepts both single types and array types (draft-07 nullable syntax).
* Array types like ["string", "null"] are transformed to anyOf format for 2020-12 compliance.
*/
const TypeFieldSchema = z.union([JsonSchemaTypeSchema, z.array(JsonSchemaTypeSchema)])
/**
* Internal Zod schema that normalizes tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
*
* This schema performs three key transformations:
* 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode)
* 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format
* (required by Claude on Bedrock which enforces JSON Schema draft 2020-12)
* 3. Strips unsupported `format` values (e.g., "uri") for OpenAI Structured Outputs compatibility
*
* Uses recursive parsing so transformations apply to all nested schemas automatically.
*/
const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodTypeDef, Record<string, unknown>> = z.lazy(
() =>
z
.object({
// Accept both single type and array of types, transform array to anyOf
type: TypeFieldSchema.optional(),
properties: z.record(z.string(), NormalizedToolSchemaInternal).optional(),
items: z.union([NormalizedToolSchemaInternal, z.array(NormalizedToolSchemaInternal)]).optional(),
required: z.array(z.string()).optional(),
// Don't set default here - we'll handle it conditionally in the transform
additionalProperties: z.union([z.boolean(), NormalizedToolSchemaInternal]).optional(),
description: z.string().optional(),
default: z.unknown().optional(),
enum: z.array(JsonSchemaEnumValueSchema).optional(),
const: JsonSchemaEnumValueSchema.optional(),
anyOf: z.array(NormalizedToolSchemaInternal).optional(),
oneOf: z.array(NormalizedToolSchemaInternal).optional(),
allOf: z.array(NormalizedToolSchemaInternal).optional(),
$ref: z.string().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
pattern: z.string().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
// Format field - unsupported values will be stripped in transform
format: z.string().optional(),
})
.passthrough()
.transform((schema) => {
const {
type,
required,
properties,
additionalProperties,
format,
items,
minItems,
maxItems,
uniqueItems,
...rest
} = schema
const result: Record<string, unknown> = { ...rest }
// Determine if this schema represents an object type
const isObjectType =
type === "object" || (Array.isArray(type) && type.includes("object")) || properties !== undefined
// Collect array-specific properties for potential use in type handling
const arrayProps = { items, minItems, maxItems, uniqueItems }
// If type is an array, convert to anyOf format (JSON Schema 2020-12)
// Array-specific properties must be moved inside the array variant
if (Array.isArray(type)) {
result.anyOf = type.map((t) => {
if (t === "array") {
return applyArrayProperties({ type: t }, arrayProps)
}
return { type: t }
})
} else if (type !== undefined) {
result.type = type
// For single "array" type, preserve array-specific properties at root
if (type === "array") {
applyArrayProperties(result, arrayProps)
}
}
// Strip unsupported format values for OpenAI compatibility
// Only include format if it's a supported value
if (format && OPENAI_SUPPORTED_FORMATS.has(format)) {
result.format = format
}
// Handle properties and required for strict mode
if (properties) {
result.properties = properties
if (required) {
const propertyKeys = Object.keys(properties)
const filteredRequired = required.filter((key) => propertyKeys.includes(key))
if (filteredRequired.length > 0) {
result.required = filteredRequired
}
}
} else if (result.type === "object" || (Array.isArray(type) && type.includes("object"))) {
// For type: "object" without properties, add empty properties
// This is required by OpenAI strict mode
result.properties = {}
}
// Only add additionalProperties for object-type schemas
// Adding it to primitive types (string, number, etc.) is invalid JSON Schema
if (isObjectType) {
// For strict mode compatibility, we MUST set additionalProperties to false
// Even if the original schema had {} (any) or true, we force false because
// OpenAI/OpenRouter strict mode rejects schemas with additionalProperties != false
// The original schema intent (allowing arbitrary properties) is incompatible with strict mode
result.additionalProperties = false
}
// For non-object types, don't include additionalProperties at all
return result
}),
)
/**
* Normalizes a tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
*
* This function performs three key transformations:
* 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode)
* 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format
* (required by Claude on Bedrock which enforces JSON Schema draft 2020-12)
* 3. Strips unsupported `format` values (e.g., "uri") for OpenAI Structured Outputs compatibility
*
* Uses recursive parsing so transformations apply to all nested schemas automatically.
*
* @param schema - The JSON Schema to normalize
* @returns A normalized schema object that is JSON Schema draft 2020-12 compliant
*/
export function normalizeToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (typeof schema !== "object" || schema === null) {
return schema
}
const result = NormalizedToolSchemaInternal.safeParse(schema)
return result.success ? result.data : schema
}