Skip to content

Commit d299e16

Browse files
authored
Add ChatResponseFormat.ForJsonSchema<T> (#6786)
* Add ChatResponseFormat.ForJsonSchema<T> Moves the implementation used by `GetResponseAsync<T>` to be exposed as its own helper that can be used directly, for cases where folks want the formatting but don't want to use the generic `GetResponseAsync<T>`. * Address PR feedback
1 parent 714cfdb commit d299e16

File tree

8 files changed

+245
-128
lines changed

8 files changed

+245
-128
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## NOT YET RELEASED
44

5+
- Added new `ChatResponseFormat.ForJsonSchema` overloads that export a JSON schema from a .NET type.
6+
57
## 9.9.0
68

79
- Added non-invocable `AIFunctionDeclaration` (base class for `AIFunction`), `AIFunctionFactory.CreateDeclaration`, and `AIFunction.AsDeclarationOnly`.

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System;
5+
using System.ComponentModel;
6+
using System.Reflection;
47
using System.Text.Json;
58
using System.Text.Json.Serialization;
9+
using System.Text.RegularExpressions;
10+
using Microsoft.Shared.Diagnostics;
611

712
namespace Microsoft.Extensions.AI;
813

14+
#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable
15+
#pragma warning disable S2333 // gratuitous partial
16+
917
/// <summary>Represents the response format that is desired by the caller.</summary>
1018
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
1119
[JsonDerivedType(typeof(ChatResponseFormatText), typeDiscriminator: "text")]
1220
[JsonDerivedType(typeof(ChatResponseFormatJson), typeDiscriminator: "json")]
13-
#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable
14-
public class ChatResponseFormat
15-
#pragma warning restore CA1052
21+
public partial class ChatResponseFormat
1622
{
23+
private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new()
24+
{
25+
IncludeSchemaKeyword = true,
26+
};
27+
1728
/// <summary>Initializes a new instance of the <see cref="ChatResponseFormat"/> class.</summary>
1829
/// <remarks>Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it.</remarks>
1930
private protected ChatResponseFormat()
@@ -33,7 +44,61 @@ private protected ChatResponseFormat()
3344
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
3445
public static ChatResponseFormatJson ForJsonSchema(
3546
JsonElement schema, string? schemaName = null, string? schemaDescription = null) =>
36-
new(schema,
37-
schemaName,
38-
schemaDescription);
47+
new(schema, schemaName, schemaDescription);
48+
49+
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with a schema based on <typeparamref name="T"/>.</summary>
50+
/// <typeparam name="T">The type for which a schema should be exported and used as the response schema.</typeparam>
51+
/// <param name="serializerOptions">The JSON serialization options to use.</param>
52+
/// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param>
53+
/// <param name="schemaDescription">An optional description of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param>
54+
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
55+
/// <remarks>
56+
/// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'.
57+
/// If <typeparamref name="T"/> is a primitive type like <see cref="string"/>, <see cref="int"/>, or <see cref="bool"/>,
58+
/// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail.
59+
/// In such cases, consider instead using a <typeparamref name="T"/> that wraps the actual type in a class or struct so that
60+
/// it serializes as a JSON object with the original type as a property of that object.
61+
/// </remarks>
62+
public static ChatResponseFormatJson ForJsonSchema<T>(
63+
JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) =>
64+
ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription);
65+
66+
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with a schema based on <paramref name="schemaType"/>.</summary>
67+
/// <param name="schemaType">The <see cref="Type"/> for which a schema should be exported and used as the response schema.</param>
68+
/// <param name="serializerOptions">The JSON serialization options to use.</param>
69+
/// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <paramref name="schemaType"/>.</param>
70+
/// <param name="schemaDescription">An optional description of the schema. By default, this will be inferred from <paramref name="schemaType"/>.</param>
71+
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
72+
/// <remarks>
73+
/// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'.
74+
/// If <paramref name="schemaType"/> is a primitive type like <see cref="string"/>, <see cref="int"/>, or <see cref="bool"/>,
75+
/// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail.
76+
/// In such cases, consider instead using a <paramref name="schemaType"/> that wraps the actual type in a class or struct so that
77+
/// it serializes as a JSON object with the original type as a property of that object.
78+
/// </remarks>
79+
/// <exception cref="ArgumentNullException"><paramref name="schemaType"/> is <see langword="null"/>.</exception>
80+
public static ChatResponseFormatJson ForJsonSchema(
81+
Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null)
82+
{
83+
_ = Throw.IfNull(schemaType);
84+
85+
var schema = AIJsonUtilities.CreateJsonSchema(
86+
schemaType,
87+
serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions,
88+
inferenceOptions: _inferenceOptions);
89+
90+
return ForJsonSchema(
91+
schema,
92+
schemaName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"),
93+
schemaDescription ?? schemaType.GetCustomAttribute<DescriptionAttribute>()?.Description);
94+
}
95+
96+
/// <summary>Regex that flags any character other than ASCII digits, ASCII letters, or underscore.</summary>
97+
#if NET
98+
[GeneratedRegex("[^0-9A-Za-z_]")]
99+
private static partial Regex InvalidNameCharsRegex();
100+
#else
101+
private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex;
102+
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled);
103+
#endif
39104
}

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public class DelegatingChatClient : IChatClient
2323
/// Initializes a new instance of the <see cref="DelegatingChatClient"/> class.
2424
/// </summary>
2525
/// <param name="innerClient">The wrapped client instance.</param>
26+
/// <exception cref="ArgumentNullException"><paramref name="innerClient"/> is <see langword="null"/>.</exception>
2627
protected DelegatingChatClient(IChatClient innerClient)
2728
{
2829
InnerClient = Throw.IfNull(innerClient);

src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,14 @@
11561156
{
11571157
"Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonElement schema, string? schemaName = null, string? schemaDescription = null);",
11581158
"Stage": "Stable"
1159+
},
1160+
{
1161+
"Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<T>(System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);",
1162+
"Stage": "Stable"
1163+
},
1164+
{
1165+
"Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Type schemaType, System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);",
1166+
"Stage": "Stable"
11591167
}
11601168
],
11611169
"Properties": [

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

Lines changed: 28 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33

44
using System;
55
using System.Collections.Generic;
6-
using System.ComponentModel;
7-
using System.Reflection;
6+
using System.Diagnostics;
87
using System.Text.Json;
98
using System.Text.Json.Nodes;
10-
using System.Text.RegularExpressions;
119
using System.Threading;
1210
using System.Threading.Tasks;
1311
using Microsoft.Shared.Diagnostics;
@@ -23,17 +21,6 @@ namespace Microsoft.Extensions.AI;
2321
/// <related type="Article" href="https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output">Request a response with structured output.</related>
2422
public static partial class ChatClientStructuredOutputExtensions
2523
{
26-
private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new()
27-
{
28-
IncludeSchemaKeyword = true,
29-
TransformOptions = new AIJsonSchemaTransformOptions
30-
{
31-
DisallowAdditionalProperties = true,
32-
RequireAllProperties = true,
33-
MoveDefaultKeywordToDescription = true,
34-
},
35-
};
36-
3724
/// <summary>Sends chat messages, requesting a response matching the type <typeparamref name="T"/>.</summary>
3825
/// <param name="chatClient">The <see cref="IChatClient"/>.</param>
3926
/// <param name="messages">The chat content to send.</param>
@@ -161,20 +148,12 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
161148

162149
serializerOptions.MakeReadOnly();
163150

164-
var schemaElement = AIJsonUtilities.CreateJsonSchema(
165-
type: typeof(T),
166-
serializerOptions: serializerOptions,
167-
inferenceOptions: _inferenceOptions);
151+
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
168152

169-
bool isWrappedInObject;
170-
JsonElement schema;
171-
if (SchemaRepresentsObject(schemaElement))
172-
{
173-
// For object-representing schemas, we can use them as-is
174-
isWrappedInObject = false;
175-
schema = schemaElement;
176-
}
177-
else
153+
Debug.Assert(responseFormat.Schema is not null, "ForJsonSchema should always populate Schema");
154+
var schema = responseFormat.Schema!.Value;
155+
bool isWrappedInObject = false;
156+
if (!SchemaRepresentsObject(schema))
178157
{
179158
// For non-object-representing schemas, we wrap them in an object schema, because all
180159
// the real LLM providers today require an object schema as the root. This is currently
@@ -184,10 +163,11 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
184163
{
185164
{ "$schema", "https://json-schema.org/draft/2020-12/schema" },
186165
{ "type", "object" },
187-
{ "properties", new JsonObject { { "data", JsonElementToJsonNode(schemaElement) } } },
166+
{ "properties", new JsonObject { { "data", JsonElementToJsonNode(schema) } } },
188167
{ "additionalProperties", false },
189168
{ "required", new JsonArray("data") },
190169
}, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject)));
170+
responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription);
191171
}
192172

193173
ChatMessage? promptAugmentation = null;
@@ -200,10 +180,7 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
200180
{
201181
// When using native structured output, we don't add any additional prompt, because
202182
// the LLM backend is meant to do whatever's needed to explain the schema to the LLM.
203-
options.ResponseFormat = ChatResponseFormat.ForJsonSchema(
204-
schema,
205-
schemaName: SanitizeMemberName(typeof(T).Name),
206-
schemaDescription: typeof(T).GetCustomAttribute<DescriptionAttribute>()?.Description);
183+
options.ResponseFormat = responseFormat;
207184
}
208185
else
209186
{
@@ -213,7 +190,7 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
213190
promptAugmentation = new ChatMessage(ChatRole.User, $$"""
214191
Respond with a JSON value conforming to the following schema:
215192
```
216-
{{schema}}
193+
{{responseFormat.Schema}}
217194
```
218195
""");
219196

@@ -222,53 +199,31 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
222199

223200
var result = await chatClient.GetResponseAsync(messages, options, cancellationToken);
224201
return new ChatResponse<T>(result, serializerOptions) { IsWrappedInObject = isWrappedInObject };
225-
}
226202

227-
private static bool SchemaRepresentsObject(JsonElement schemaElement)
228-
{
229-
if (schemaElement.ValueKind is JsonValueKind.Object)
203+
static bool SchemaRepresentsObject(JsonElement schemaElement)
230204
{
231-
foreach (var property in schemaElement.EnumerateObject())
205+
if (schemaElement.ValueKind is JsonValueKind.Object)
232206
{
233-
if (property.NameEquals("type"u8))
207+
foreach (var property in schemaElement.EnumerateObject())
234208
{
235-
return property.Value.ValueKind == JsonValueKind.String
236-
&& property.Value.ValueEquals("object"u8);
209+
if (property.NameEquals("type"u8))
210+
{
211+
return property.Value.ValueKind == JsonValueKind.String
212+
&& property.Value.ValueEquals("object"u8);
213+
}
237214
}
238215
}
239-
}
240216

241-
return false;
242-
}
217+
return false;
218+
}
243219

244-
private static JsonNode? JsonElementToJsonNode(JsonElement element)
245-
{
246-
return element.ValueKind switch
247-
{
248-
JsonValueKind.Null => null,
249-
JsonValueKind.Array => JsonArray.Create(element),
250-
JsonValueKind.Object => JsonObject.Create(element),
251-
_ => JsonValue.Create(element)
252-
};
220+
static JsonNode? JsonElementToJsonNode(JsonElement element) =>
221+
element.ValueKind switch
222+
{
223+
JsonValueKind.Null => null,
224+
JsonValueKind.Array => JsonArray.Create(element),
225+
JsonValueKind.Object => JsonObject.Create(element),
226+
_ => JsonValue.Create(element)
227+
};
253228
}
254-
255-
/// <summary>
256-
/// Removes characters from a .NET member name that shouldn't be used in an AI function name.
257-
/// </summary>
258-
/// <param name="memberName">The .NET member name that should be sanitized.</param>
259-
/// <returns>
260-
/// Replaces non-alphanumeric characters in the identifier with the underscore character.
261-
/// Primarily intended to remove characters produced by compiler-generated method name mangling.
262-
/// </returns>
263-
private static string SanitizeMemberName(string memberName) =>
264-
InvalidNameCharsRegex().Replace(memberName, "_");
265-
266-
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
267-
#if NET
268-
[GeneratedRegex("[^0-9A-Za-z_]")]
269-
private static partial Regex InvalidNameCharsRegex();
270-
#else
271-
private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex;
272-
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled);
273-
#endif
274229
}

0 commit comments

Comments
 (0)