Skip to content

Commit 7386635

Browse files
committed
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>`.
1 parent 4270c00 commit 7386635

File tree

5 files changed

+161
-79
lines changed

5 files changed

+161
-79
lines changed

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

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
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.ComponentModel;
5+
using System.Reflection;
46
using System.Text.Json;
57
using System.Text.Json.Serialization;
8+
using System.Text.RegularExpressions;
69

710
namespace Microsoft.Extensions.AI;
811

12+
#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable
13+
#pragma warning disable S2333 // gratiutious partial
14+
915
/// <summary>Represents the response format that is desired by the caller.</summary>
1016
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
1117
[JsonDerivedType(typeof(ChatResponseFormatText), typeDiscriminator: "text")]
1218
[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
19+
public partial class ChatResponseFormat
1620
{
21+
private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new()
22+
{
23+
IncludeSchemaKeyword = true,
24+
TransformOptions = new AIJsonSchemaTransformOptions
25+
{
26+
DisallowAdditionalProperties = true,
27+
RequireAllProperties = true,
28+
MoveDefaultKeywordToDescription = true,
29+
},
30+
};
31+
1732
/// <summary>Initializes a new instance of the <see cref="ChatResponseFormat"/> class.</summary>
1833
/// <remarks>Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it.</remarks>
1934
private protected ChatResponseFormat()
@@ -33,7 +48,41 @@ private protected ChatResponseFormat()
3348
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
3449
public static ChatResponseFormatJson ForJsonSchema(
3550
JsonElement schema, string? schemaName = null, string? schemaDescription = null) =>
36-
new(schema,
37-
schemaName,
38-
schemaDescription);
51+
new(schema, schemaName, schemaDescription);
52+
53+
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with the specified schema.</summary>
54+
/// <typeparam name="T">The type for which a schema should be exported and used as the response schema.</typeparam>
55+
/// <param name="serializerOptions">The JSON serialization options to use.</param>
56+
/// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param>
57+
/// <param name="schemaDescription">An optional description of the schema. An optional name of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param>
58+
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
59+
/// <remarks>
60+
/// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'.
61+
/// If <typeparamref name="T"/> is a primitive type like <see cref="string"/>, <see cref="int"/>, or <see cref="bool"/>,
62+
/// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail.
63+
/// In such cases, consider instead using a <typeparamref name="T"/> that wraps the actual type in a class or struct so that
64+
/// it serializes as a JSON object with the original type as a property of that object.
65+
/// </remarks>
66+
public static ChatResponseFormatJson ForJsonSchema<T>(
67+
JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null)
68+
{
69+
var schema = AIJsonUtilities.CreateJsonSchema(
70+
type: typeof(T),
71+
serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions,
72+
inferenceOptions: _inferenceOptions);
73+
74+
return ForJsonSchema(
75+
schema,
76+
schemaName ?? InvalidNameCharsRegex().Replace(typeof(T).Name, "_"),
77+
schemaDescription ?? typeof(T).GetCustomAttribute<DescriptionAttribute>()?.Description);
78+
}
79+
80+
/// <summary>Regex that flags any character other than ASCII digits, ASCII letters, or underscore.</summary>
81+
#if NET
82+
[GeneratedRegex("[^0-9A-Za-z_]")]
83+
private static partial Regex InvalidNameCharsRegex();
84+
#else
85+
private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex;
86+
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled);
87+
#endif
3988
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,10 @@
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"
11591163
}
11601164
],
11611165
"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
}

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.ComponentModel;
56
using System.Text.Json;
67
using Xunit;
78

@@ -81,4 +82,76 @@ public void Serialization_ForJsonSchemaRoundtrips()
8182
Assert.Equal("name", actual.SchemaName);
8283
Assert.Equal("description", actual.SchemaDescription);
8384
}
85+
86+
[Fact]
87+
public void ForJsonSchema_PrimitiveType_Succeeds()
88+
{
89+
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<int>();
90+
Assert.NotNull(format);
91+
Assert.NotNull(format.Schema);
92+
Assert.Equal("""{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"integer"}""", format.Schema.ToString());
93+
Assert.Equal("Int32", format.SchemaName);
94+
Assert.Null(format.SchemaDescription);
95+
}
96+
97+
[Fact]
98+
public void ForJsonSchema_IncludedType_Succeeds()
99+
{
100+
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<DataContent>();
101+
Assert.NotNull(format);
102+
Assert.NotNull(format.Schema);
103+
Assert.Contains("A data URI representing the content.", format.Schema.ToString());
104+
Assert.Equal("DataContent", format.SchemaName);
105+
Assert.Null(format.SchemaDescription);
106+
}
107+
108+
[Theory]
109+
[InlineData(null, null)]
110+
[InlineData("AnotherName", null)]
111+
[InlineData(null, "another description")]
112+
[InlineData("AnotherName", "another description")]
113+
public void ForJsonSchema_ComplexType_Succeeds(string? name, string? description)
114+
{
115+
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<SomeType>(TestJsonSerializerContext.Default.Options, name, description);
116+
Assert.NotNull(format);
117+
Assert.Equal(
118+
"""
119+
{
120+
"$schema": "https://json-schema.org/draft/2020-12/schema",
121+
"description": "abcd",
122+
"type": "object",
123+
"properties": {
124+
"someInteger": {
125+
"description": "efg",
126+
"type": "integer"
127+
},
128+
"someString": {
129+
"description": "hijk",
130+
"type": [
131+
"string",
132+
"null"
133+
]
134+
}
135+
},
136+
"additionalProperties": false,
137+
"required": [
138+
"someInteger",
139+
"someString"
140+
]
141+
}
142+
""",
143+
JsonSerializer.Serialize(format.Schema, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))));
144+
Assert.Equal(name ?? "SomeType", format.SchemaName);
145+
Assert.Equal(description ?? "abcd", format.SchemaDescription);
146+
}
147+
148+
[Description("abcd")]
149+
public class SomeType
150+
{
151+
[Description("efg")]
152+
public int SomeInteger { get; set; }
153+
154+
[Description("hijk")]
155+
public string? SomeString { get; set; }
156+
}
84157
}

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ namespace Microsoft.Extensions.AI;
3636
[JsonSerializable(typeof(Guid))] // Used in Content tests
3737
[JsonSerializable(typeof(decimal))] // Used in Content tests
3838
[JsonSerializable(typeof(HostedMcpServerToolApprovalMode))]
39+
[JsonSerializable(typeof(ChatResponseFormatTests.SomeType))]
3940
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;

0 commit comments

Comments
 (0)