Skip to content

Commit 08fbb67

Browse files
Add an AIJsonSchemaTransformOptions property inside AIJsonSchemaCreateOptions and mark redundant properties in the latter as obsolete. (#6427)
* Add an AIJsonSchemaTransformOptions property inside AIJsonSchemaCreateOptions and mark redundant properties in the latter as obsolete. * s/inferred/created
1 parent 3c82ac5 commit 08fbb67

File tree

10 files changed

+175
-108
lines changed

10 files changed

+175
-108
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,33 @@ public sealed record class AIJsonSchemaCreateOptions
3838
public Func<ParameterInfo, bool>? IncludeParameter { get; init; }
3939

4040
/// <summary>
41-
/// Gets a value indicating whether to include the type keyword in inferred schemas for .NET enums.
41+
/// Gets a <see cref="AIJsonSchemaTransformOptions"/> governing transformations on the JSON schema after it has been generated.
4242
/// </summary>
43+
public AIJsonSchemaTransformOptions? TransformOptions { get; init; }
44+
45+
/// <summary>
46+
/// Gets a value indicating whether to include the type keyword in created schemas for .NET enums.
47+
/// </summary>
48+
[Obsolete("This property has been deprecated.")]
49+
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
4350
public bool IncludeTypeInEnumSchemas { get; init; } = true;
4451

4552
/// <summary>
4653
/// Gets a value indicating whether to generate schemas with the additionalProperties set to false for .NET objects.
4754
/// </summary>
48-
public bool DisallowAdditionalProperties { get; init; } = true;
55+
[Obsolete("This property has been deprecated. Use the equivalent property in TransformOptions instead.")]
56+
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
57+
public bool DisallowAdditionalProperties { get; init; }
4958

5059
/// <summary>
51-
/// Gets a value indicating whether to include the $schema keyword in inferred schemas.
60+
/// Gets a value indicating whether to include the $schema keyword in created schemas.
5261
/// </summary>
5362
public bool IncludeSchemaKeyword { get; init; }
5463

5564
/// <summary>
5665
/// Gets a value indicating whether to mark all properties as required in the schema.
5766
/// </summary>
67+
[Obsolete("This property has been deprecated. Use the equivalent property in TransformOptions instead.")]
68+
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
5869
public bool RequireAllProperties { get; init; }
5970
}

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ public sealed record class AIJsonSchemaTransformOptions
3838
/// </summary>
3939
public bool UseNullableKeyword { get; init; }
4040

41+
/// <summary>
42+
/// Gets a value indicating whether to move the default keyword to the description field in the schema.
43+
/// </summary>
44+
public bool MoveDefaultKeywordToDescription { get; init; }
45+
4146
/// <summary>
4247
/// Gets the default options instance.
4348
/// </summary>

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,11 @@ private static JsonSerializerOptions CreateDefaultOptions()
116116
[JsonSerializable(typeof(AIFunctionArguments))]
117117
[EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead.
118118
private sealed partial class JsonContext : JsonSerializerContext;
119+
120+
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
121+
UseStringEnumConverter = true,
122+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
123+
WriteIndented = false)]
124+
[JsonSerializable(typeof(JsonNode))]
125+
private sealed partial class JsonContextNoIndentation : JsonSerializerContext;
119126
}

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs

Lines changed: 28 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using System.ComponentModel;
66
using System.Diagnostics;
77
using System.Diagnostics.CodeAnalysis;
8-
using System.Linq;
98
using System.Reflection;
109
using System.Runtime.CompilerServices;
1110
using System.Text.Json;
@@ -40,7 +39,7 @@ public static partial class AIJsonUtilities
4039
private const string DefaultPropertyName = "default";
4140
private const string RefPropertyName = "$ref";
4241

43-
/// <summary>The uri used when populating the $schema keyword in inferred schemas.</summary>
42+
/// <summary>The uri used when populating the $schema keyword in created schemas.</summary>
4443
private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema";
4544

4645
// List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors.
@@ -54,7 +53,7 @@ public static partial class AIJsonUtilities
5453
/// <param name="title">The title keyword used by the method schema.</param>
5554
/// <param name="description">The description keyword used by the method schema.</param>
5655
/// <param name="serializerOptions">The options used to extract the schema from the specified type.</param>
57-
/// <param name="inferenceOptions">The options controlling schema inference.</param>
56+
/// <param name="inferenceOptions">The options controlling schema creation.</param>
5857
/// <returns>A JSON schema document encoded as a <see cref="JsonElement"/>.</returns>
5958
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
6059
public static JsonElement CreateFunctionJsonSchema(
@@ -106,13 +105,13 @@ public static JsonElement CreateFunctionJsonSchema(
106105
inferenceOptions);
107106

108107
parameterSchemas.Add(parameter.Name, parameterSchema);
109-
if (!parameter.IsOptional || inferenceOptions.RequireAllProperties)
108+
if (!parameter.IsOptional)
110109
{
111110
(requiredProperties ??= []).Add((JsonNode)parameter.Name);
112111
}
113112
}
114113

115-
JsonObject schema = new();
114+
JsonNode schema = new JsonObject();
116115
if (inferenceOptions.IncludeSchemaKeyword)
117116
{
118117
schema[SchemaPropertyName] = SchemaKeywordUri;
@@ -136,7 +135,13 @@ public static JsonElement CreateFunctionJsonSchema(
136135
schema[RequiredPropertyName] = requiredProperties;
137136
}
138137

139-
return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode);
138+
// Finally, apply any schema transformations if specified.
139+
if (inferenceOptions.TransformOptions is { } options)
140+
{
141+
schema = TransformSchema(schema, options);
142+
}
143+
144+
return JsonSerializer.SerializeToElement(schema, JsonContextNoIndentation.Default.JsonNode);
140145
}
141146

142147
/// <summary>Creates a JSON schema for the specified type.</summary>
@@ -145,7 +150,7 @@ public static JsonElement CreateFunctionJsonSchema(
145150
/// <param name="hasDefaultValue"><see langword="true"/> if the parameter is optional; otherwise, <see langword="false"/>.</param>
146151
/// <param name="defaultValue">The default value of the optional parameter, if applicable.</param>
147152
/// <param name="serializerOptions">The options used to extract the schema from the specified type.</param>
148-
/// <param name="inferenceOptions">The options controlling schema inference.</param>
153+
/// <param name="inferenceOptions">The options controlling schema creation.</param>
149154
/// <returns>A <see cref="JsonElement"/> representing the schema.</returns>
150155
public static JsonElement CreateJsonSchema(
151156
Type? type,
@@ -158,7 +163,14 @@ public static JsonElement CreateJsonSchema(
158163
serializerOptions ??= DefaultOptions;
159164
inferenceOptions ??= AIJsonSchemaCreateOptions.Default;
160165
JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions);
161-
return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode);
166+
167+
// Finally, apply any schema transformations if specified.
168+
if (inferenceOptions.TransformOptions is { } options)
169+
{
170+
schema = TransformSchema(schema, options);
171+
}
172+
173+
return JsonSerializer.SerializeToElement(schema, JsonContextNoIndentation.Default.JsonNode);
162174
}
163175

164176
/// <summary>Gets the default JSON schema to be used by types or functions.</summary>
@@ -203,25 +215,11 @@ private static JsonNode CreateJsonSchemaCore(
203215

204216
if (hasDefaultValue)
205217
{
206-
if (inferenceOptions.RequireAllProperties)
207-
{
208-
// Default values are only used in the context of optional parameters.
209-
// Do not include a default keyword (since certain AI vendors don't support it)
210-
// and instead embed its JSON in the description as a hint to the LLM.
211-
string defaultValueJson = defaultValue is not null
212-
? JsonSerializer.Serialize(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType()))
213-
: "null";
214-
215-
description = CreateDescriptionWithDefaultValue(description, defaultValueJson);
216-
}
217-
else
218-
{
219-
JsonNode? defaultValueNode = defaultValue is not null
220-
? JsonSerializer.SerializeToNode(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType()))
221-
: null;
218+
JsonNode? defaultValueNode = defaultValue is not null
219+
? JsonSerializer.SerializeToNode(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType()))
220+
: null;
222221

223-
(schemaObj ??= [])[DefaultPropertyName] = defaultValueNode;
224-
}
222+
(schemaObj ??= [])[DefaultPropertyName] = defaultValueNode;
225223
}
226224

227225
if (description is not null)
@@ -271,41 +269,11 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
271269
}
272270

273271
// Include the type keyword in enum types
274-
if (inferenceOptions.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName))
272+
if (ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName))
275273
{
276274
objSchema.InsertAtStart(TypePropertyName, "string");
277275
}
278276

279-
// Disallow additional properties in object schemas
280-
if (inferenceOptions.DisallowAdditionalProperties &&
281-
objSchema.ContainsKey(PropertiesPropertyName) &&
282-
!objSchema.ContainsKey(AdditionalPropertiesPropertyName))
283-
{
284-
objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false);
285-
}
286-
287-
// Mark all properties as required
288-
if (inferenceOptions.RequireAllProperties &&
289-
objSchema.TryGetPropertyValue(PropertiesPropertyName, out JsonNode? properties) &&
290-
properties is JsonObject propertiesObj)
291-
{
292-
_ = objSchema.TryGetPropertyValue(RequiredPropertyName, out JsonNode? required);
293-
if (required is not JsonArray { } requiredArray || requiredArray.Count != propertiesObj.Count)
294-
{
295-
requiredArray = [.. propertiesObj.Select(prop => (JsonNode)prop.Key)];
296-
objSchema[RequiredPropertyName] = requiredArray;
297-
}
298-
}
299-
300-
// Strip default keywords and embed in description where required
301-
if (inferenceOptions.RequireAllProperties &&
302-
objSchema.TryGetPropertyValue(DefaultPropertyName, out JsonNode? defaultValue))
303-
{
304-
_ = objSchema.Remove(DefaultPropertyName);
305-
string defaultValueJson = defaultValue?.ToJsonString() ?? "null";
306-
localDescription = CreateDescriptionWithDefaultValue(localDescription, defaultValueJson);
307-
}
308-
309277
// Filter potentially disallowed keywords.
310278
foreach (string keyword in _schemaKeywordsDisallowedByAIVendors)
311279
{
@@ -328,20 +296,8 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
328296

329297
if (ctx.Path.IsEmpty && hasDefaultValue)
330298
{
331-
// Add root-level default value metadata
332-
if (inferenceOptions.RequireAllProperties)
333-
{
334-
// Default values are only used in the context of optional parameters.
335-
// Do not include a default keyword (since certain AI vendors don't support it)
336-
// and instead embed its JSON in the description as a hint to the LLM.
337-
string defaultValueJson = JsonSerializer.Serialize(defaultValue, ctx.TypeInfo);
338-
localDescription = CreateDescriptionWithDefaultValue(localDescription, defaultValueJson);
339-
}
340-
else
341-
{
342-
JsonNode? defaultValueNode = JsonSerializer.SerializeToNode(defaultValue, ctx.TypeInfo);
343-
ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValueNode;
344-
}
299+
JsonNode? defaultValueNode = JsonSerializer.SerializeToNode(defaultValue, ctx.TypeInfo);
300+
ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValueNode;
345301
}
346302

347303
if (localDescription is not null)
@@ -423,7 +379,7 @@ private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNo
423379
jsonObject.Insert(0, key, value);
424380
#else
425381
jsonObject.Remove(key);
426-
var copiedEntries = jsonObject.ToArray();
382+
var copiedEntries = System.Linq.Enumerable.ToArray(jsonObject);
427383
jsonObject.Clear();
428384

429385
jsonObject.Add(key, value);
@@ -434,13 +390,6 @@ private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNo
434390
#endif
435391
}
436392

437-
private static string CreateDescriptionWithDefaultValue(string? existingDescription, string defaultValueJson)
438-
{
439-
return existingDescription is null
440-
? $"Default value: {defaultValueJson}"
441-
: $"{existingDescription} (Default value: {defaultValueJson})";
442-
}
443-
444393
private static JsonElement ParseJsonElement(ReadOnlySpan<byte> utf8Json)
445394
{
446395
Utf8JsonReader reader = new(utf8Json);

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Transform.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,14 @@ public static JsonElement TransformSchema(JsonElement schema, AIJsonSchemaTransf
3030
}
3131

3232
JsonNode? nodeSchema = JsonSerializer.SerializeToNode(schema, JsonContext.Default.JsonElement);
33+
JsonNode transformedSchema = TransformSchema(nodeSchema, transformOptions);
34+
return JsonSerializer.SerializeToElement(transformedSchema, JsonContextNoIndentation.Default.JsonNode);
35+
}
36+
37+
private static JsonNode TransformSchema(JsonNode? schema, AIJsonSchemaTransformOptions transformOptions)
38+
{
3339
List<string>? path = transformOptions.TransformSchemaNode is not null ? [] : null;
34-
JsonNode transformedSchema = TransformSchemaCore(nodeSchema, transformOptions, path);
35-
return JsonSerializer.Deserialize(transformedSchema, JsonContext.Default.JsonElement);
40+
return TransformSchemaCore(schema, transformOptions, path);
3641
}
3742

3843
private static JsonNode TransformSchemaCore(JsonNode? schema, AIJsonSchemaTransformOptions transformOptions, List<string>? path)
@@ -169,6 +174,18 @@ private static JsonNode TransformSchemaCore(JsonNode? schema, AIJsonSchemaTransf
169174
}
170175
}
171176

177+
if (transformOptions.MoveDefaultKeywordToDescription &&
178+
schemaObj.TryGetPropertyValue(DefaultPropertyName, out JsonNode? defaultSchema))
179+
{
180+
string? description = schemaObj.TryGetPropertyValue(DescriptionPropertyName, out JsonNode? descriptionSchema) ? descriptionSchema?.GetValue<string>() : null;
181+
string defaultValueJson = JsonSerializer.Serialize(defaultSchema, JsonContextNoIndentation.Default.JsonNode!);
182+
description = description is null
183+
? $"Default value: {defaultValueJson}"
184+
: $"{description} (Default value: {defaultValueJson})";
185+
schemaObj[DescriptionPropertyName] = description;
186+
_ = schemaObj.Remove(DefaultPropertyName);
187+
}
188+
172189
break;
173190

174191
default:

src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ internal sealed class AzureAIInferenceChatClient : IChatClient
2929
{
3030
RequireAllProperties = true,
3131
DisallowAdditionalProperties = true,
32-
ConvertBooleanSchemas = true
32+
ConvertBooleanSchemas = true,
33+
MoveDefaultKeywordToDescription = true,
3334
});
3435

3536
/// <summary>Metadata about the client.</summary>

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ internal sealed partial class OpenAIChatClient : IChatClient
3030
{
3131
RequireAllProperties = true,
3232
DisallowAdditionalProperties = true,
33-
ConvertBooleanSchemas = true
33+
ConvertBooleanSchemas = true,
34+
MoveDefaultKeywordToDescription = true,
3435
});
3536

3637
/// <summary>Gets the default OpenAI endpoint.</summary>

src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ public static partial class ChatClientStructuredOutputExtensions
2626
private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new()
2727
{
2828
IncludeSchemaKeyword = true,
29-
DisallowAdditionalProperties = true,
30-
IncludeTypeInEnumSchemas = true,
31-
RequireAllProperties = true,
29+
TransformOptions = new AIJsonSchemaTransformOptions
30+
{
31+
DisallowAdditionalProperties = true,
32+
RequireAllProperties = true,
33+
MoveDefaultKeywordToDescription = true,
34+
},
3235
};
3336

3437
/// <summary>Sends chat messages, requesting a response matching the type <typeparamref name="T"/>.</summary>

0 commit comments

Comments
 (0)