-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathOpenAIJsonSchema.cs
317 lines (276 loc) · 10.9 KB
/
OpenAIJsonSchema.cs
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
using System.Collections;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
namespace StructuredOutputs;
/// <summary>
/// A utility class to generate JSON Schema compatible with OpenAI's Structured Outputs subset.
/// </summary>
public static class OpenAIJsonSchema
{
/// <summary>
/// Generates a JSON Schema (as a string) compatible with OpenAI's Structured Outputs subset for the specified generic type.
/// </summary>
public static string For<T>() => For(typeof(T));
/// <summary>
/// Generates a JSON Schema (as a string) compatible with OpenAI's Structured Outputs subset for the specified <see cref="Type"/>.
/// </summary>
public static string For(Type t)
{
var visited = new HashSet<Type>();
var definitions = new Dictionary<string, object>();
// Build the top-level object schema
var rootSchema = BuildRootObjectSchema(t, visited, definitions);
// If we have child definitions, add them to the root
if (definitions.Count > 0)
{
rootSchema["$defs"] = definitions;
}
// Serialize
return JsonSerializer.Serialize(rootSchema, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Builds the top-level schema for an object, adding references and definitions for sub-objects.
/// </summary>
private static Dictionary<string, object> BuildRootObjectSchema(
Type t,
HashSet<Type> visited,
Dictionary<string, object> definitions)
{
// Treat the root as a standard object schema with type="object", properties=..., required=..., additionalProperties=false
var (properties, required) = BuildPropertiesForType(t, visited, definitions);
return new Dictionary<string, object>
{
["type"] = "object",
["properties"] = properties,
["required"] = required,
["additionalProperties"] = false
};
}
/// <summary>
/// Builds the property definitions and "required" list for a given type.
/// </summary>
private static (Dictionary<string, object> properties, List<string> required) BuildPropertiesForType(
Type t,
HashSet<Type> visited,
Dictionary<string, object> definitions)
{
var propsDict = new Dictionary<string, object>();
var requiredList = new List<string>();
// Only consider public instance properties
var props = t.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in props)
{
requiredList.Add(prop.Name);
// Build the JSON Schema for this property
var propertySchema = BuildPropertySchema(
prop.PropertyType,
visited,
definitions);
var description = GetDescription(prop.GetCustomAttributes());
if (!string.IsNullOrEmpty(description))
{
propertySchema["description"] = description;
}
propsDict[prop.Name] = propertySchema;
}
return (propsDict, requiredList);
}
/// <summary>
/// Decides how to represent the schema for a property type.
/// </summary>
/// <remarks>
/// Handles:
/// 1) Primitives (string, bool, numeric, enum)
/// 2) Nullable primitives/enums => anyOf [T, null]
/// 3) Arrays or Lists => { type="array", items=... }
/// 4) Complex objects => $ref to a definition, possibly with anyOf if it's a reference type
/// </remarks>
private static Dictionary<string, object> BuildPropertySchema(
Type propType,
HashSet<Type> visited,
Dictionary<string, object> definitions)
{
if (IsNullableValueType(propType))
{
var underlying = Nullable.GetUnderlyingType(propType);
var baseSchema = BuildSingleTypeSchema(underlying, visited, definitions);
return new Dictionary<string, object>
{
["anyOf"] = new List<object> { baseSchema, new Dictionary<string, object> { ["type"] = "null" } }
};
}
if (propType.IsValueType || propType == typeof(string))
{
// If it's a non-nullable primitive or enum or string
return BuildSingleTypeSchema(propType, visited, definitions);
}
// If it's some kind of enumerable (array/list) => array schema
if (typeof(IEnumerable).IsAssignableFrom(propType) && propType != typeof(string))
{
var arrSchema = BuildArraySchema(propType, visited, definitions);
return new Dictionary<string, object>
{
["anyOf"] = new List<object> { arrSchema, new Dictionary<string, object> { ["type"] = "null" } }
};
}
// Otherwise, it's a complex object => $ref
var refSchema = BuildComplexObjectRef(propType, visited, definitions);
return new Dictionary<string, object>
{
["anyOf"] = new List<object> { refSchema, new Dictionary<string, object> { ["type"] = "null" } }
};
}
private static bool IsNullableValueType(Type t)
{
return t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>);
}
/// <summary>
/// Builds the schema for a single type that is not wrapped by anyOf (no nulls here). This includes non-nullable primitives, enums, or reference strings.
/// </summary>
private static Dictionary<string, object> BuildSingleTypeSchema(Type t, HashSet<Type> visited, Dictionary<string, object> definitions)
{
// Enums => { "type": "string", "enum": [ ... ] }
if (t.IsEnum)
{
return new Dictionary<string, object>
{
["type"] = "string",
["enum"] = Enum.GetNames(t)
};
}
// Strings
if (t == typeof(string) || t == typeof(char) || t == typeof(DateTime))
{
return new Dictionary<string, object>
{
["type"] = "string"
};
}
// Boolean
if (t == typeof(bool))
{
return new Dictionary<string, object>
{
["type"] = "boolean"
};
}
// Numeric
switch (Type.GetTypeCode(t))
{
case TypeCode.Byte:
case TypeCode.Int16:
case TypeCode.Int32:
case TypeCode.Int64:
case TypeCode.SByte:
case TypeCode.UInt16:
case TypeCode.UInt32:
case TypeCode.UInt64:
return new Dictionary<string, object>
{
["type"] = "integer"
};
case TypeCode.Single:
case TypeCode.Double:
case TypeCode.Decimal:
return new Dictionary<string, object>
{
["type"] = "number"
};
}
// If it's an object/struct that's not an enum, we treat it as a complex object
return t is { IsValueType: true, IsPrimitive: false } ?
// Treat as an object, which might contain properties.
BuildComplexObjectRef(t, visited, definitions) :
// Fallback to string
new Dictionary<string, object>
{
["type"] = "string"
};
}
/// <summary>
/// Builds an array schema for the given collection type.
/// </summary>
private static Dictionary<string, object> BuildArraySchema(Type t, HashSet<Type> visited, Dictionary<string, object> definitions)
{
// If it's an array, element type is t.GetElementType()
// If it's a generic IEnumerable/List, element type is t.GetGenericArguments()[0]
// Otherwise fallback to object
Type? elementType = null;
if (t.IsArray)
{
elementType = t.GetElementType();
}
else if (t.IsGenericType)
{
elementType = t.GetGenericArguments().FirstOrDefault();
}
elementType ??= typeof(object);
var itemSchema = BuildPropertySchema(elementType, visited, definitions);
return new Dictionary<string, object> { ["type"] = "array", ["items"] = itemSchema };
}
/// <summary>
/// Builds or reuses a definition for a complex object type, returning a $ref.
/// </summary>
private static Dictionary<string, object> BuildComplexObjectRef(Type t, HashSet<Type> visited, Dictionary<string, object> definitions)
{
var key = GetDefinitionKey(t);
// If we've already built a definition for this type, just return a ref.
if (definitions.ContainsKey(key))
{
return new Dictionary<string, object>
{
["$ref"] = $"#/$defs/{key}"
};
}
// If we've visited this type but not built a definition, it means it's self-referencing.
// Creates an empty placeholder definition, so any references to this type will simply use the $ref instead of building it again.
if (!visited.Add(t))
{
// Create a placeholder definition if none is added
definitions[key] = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>(),
["required"] = new List<string>(),
["additionalProperties"] = false
};
return new Dictionary<string, object> { ["$ref"] = $"#/$defs/{key}" };
}
// Build and add an empty definition up front so that if we come across t again while building, we don't end up infinitely recursing.
definitions[key] = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>(),
["required"] = new List<string>(),
["additionalProperties"] = false
};
// Fill out the properties.
var (props, req) = BuildPropertiesForType(t, visited, definitions);
// Update the existing placeholder in definitions
(definitions[key] as Dictionary<string, object>)["properties"] = props;
(definitions[key] as Dictionary<string, object>)["required"] = req;
// Return the reference
return new Dictionary<string, object>
{
["$ref"] = $"#/$defs/{key}"
};
}
/// <summary>
/// Retrieves the DescriptionAttribute for a type, or falls back to the type's name.
/// </summary>
private static string GetDescription(IEnumerable<Attribute> attributes)
{
var descAttr = attributes
.OfType<DescriptionAttribute>()
.FirstOrDefault();
return descAttr?.Description ?? string.Empty;
}
/// <summary>
/// Produces a name to store in the $defs dictionary.
/// </summary>
private static string GetDefinitionKey(Type t)
{
return t.Name;
}
}