Skip to content

Commit ff2e1be

Browse files
committed
Address PR feedback
1 parent f8120a3 commit ff2e1be

File tree

6 files changed

+112
-77
lines changed

6 files changed

+112
-77
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: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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;
45
using System.ComponentModel;
56
using System.Reflection;
67
using System.Text.Json;
78
using System.Text.Json.Serialization;
89
using System.Text.RegularExpressions;
10+
using Microsoft.Shared.Diagnostics;
911

1012
namespace Microsoft.Extensions.AI;
1113

@@ -21,12 +23,6 @@ public partial class ChatResponseFormat
2123
private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new()
2224
{
2325
IncludeSchemaKeyword = true,
24-
TransformOptions = new AIJsonSchemaTransformOptions
25-
{
26-
DisallowAdditionalProperties = true,
27-
RequireAllProperties = true,
28-
MoveDefaultKeywordToDescription = true,
29-
},
3026
};
3127

3228
/// <summary>Initializes a new instance of the <see cref="ChatResponseFormat"/> class.</summary>
@@ -50,7 +46,7 @@ public static ChatResponseFormatJson ForJsonSchema(
5046
JsonElement schema, string? schemaName = null, string? schemaDescription = null) =>
5147
new(schema, schemaName, schemaDescription);
5248

53-
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with the specified schema.</summary>
49+
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with a schema based on <typeparamref name="T"/>.</summary>
5450
/// <typeparam name="T">The type for which a schema should be exported and used as the response schema.</typeparam>
5551
/// <param name="serializerOptions">The JSON serialization options to use.</param>
5652
/// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param>
@@ -64,17 +60,37 @@ public static ChatResponseFormatJson ForJsonSchema(
6460
/// it serializes as a JSON object with the original type as a property of that object.
6561
/// </remarks>
6662
public static ChatResponseFormatJson ForJsonSchema<T>(
67-
JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null)
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)
6882
{
83+
_ = Throw.IfNull(schemaType);
84+
6985
var schema = AIJsonUtilities.CreateJsonSchema(
70-
type: typeof(T),
86+
schemaType,
7187
serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions,
7288
inferenceOptions: _inferenceOptions);
7389

7490
return ForJsonSchema(
7591
schema,
76-
schemaName ?? InvalidNameCharsRegex().Replace(typeof(T).Name, "_"),
77-
schemaDescription ?? typeof(T).GetCustomAttribute<DescriptionAttribute>()?.Description);
92+
schemaName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"),
93+
schemaDescription ?? schemaType.GetCustomAttribute<DescriptionAttribute>()?.Description);
7894
}
7995

8096
/// <summary>Regex that flags any character other than ASCII digits, ASCII letters, or underscore.</summary>

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,10 @@
11601160
{
11611161
"Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<T>(System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);",
11621162
"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"
11631167
}
11641168
],
11651169
"Properties": [

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

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.ComponentModel;
7+
using System.Linq;
68
using System.Text.Json;
79
using Xunit;
810

11+
#pragma warning disable SA1204 // Static elements should appear before instance elements
12+
913
namespace Microsoft.Extensions.AI;
1014

1115
public class ChatResponseFormatTests
@@ -84,35 +88,60 @@ public void Serialization_ForJsonSchemaRoundtrips()
8488
}
8589

8690
[Fact]
87-
public void ForJsonSchema_PrimitiveType_Succeeds()
91+
public void ForJsonSchema_NullType_Throws()
92+
{
93+
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!));
94+
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options));
95+
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name"));
96+
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name", "description"));
97+
}
98+
99+
[Theory]
100+
[InlineData(false)]
101+
[InlineData(true)]
102+
public void ForJsonSchema_PrimitiveType_Succeeds(bool generic)
88103
{
89-
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<int>();
104+
ChatResponseFormatJson format = generic ?
105+
ChatResponseFormat.ForJsonSchema<int>() :
106+
ChatResponseFormat.ForJsonSchema(typeof(int));
107+
90108
Assert.NotNull(format);
91109
Assert.NotNull(format.Schema);
92110
Assert.Equal("""{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"integer"}""", format.Schema.ToString());
93111
Assert.Equal("Int32", format.SchemaName);
94112
Assert.Null(format.SchemaDescription);
95113
}
96114

97-
[Fact]
98-
public void ForJsonSchema_IncludedType_Succeeds()
115+
[Theory]
116+
[InlineData(false)]
117+
[InlineData(true)]
118+
public void ForJsonSchema_IncludedType_Succeeds(bool generic)
99119
{
100-
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<DataContent>();
120+
ChatResponseFormatJson format = generic ?
121+
ChatResponseFormat.ForJsonSchema<DataContent>() :
122+
ChatResponseFormat.ForJsonSchema(typeof(DataContent));
123+
101124
Assert.NotNull(format);
102125
Assert.NotNull(format.Schema);
103126
Assert.Contains("\"uri\"", format.Schema.ToString());
104127
Assert.Equal("DataContent", format.SchemaName);
105128
Assert.Null(format.SchemaDescription);
106129
}
107130

131+
public static IEnumerable<object?[]> ForJsonSchema_ComplexType_Succeeds_MemberData() =>
132+
from generic in new[] { false, true }
133+
from name in new string?[] { null, "CustomName" }
134+
from description in new string?[] { null, "CustomDescription" }
135+
select new object?[] { generic, name, description };
136+
108137
[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)
138+
[MemberData(nameof(ForJsonSchema_ComplexType_Succeeds_MemberData))]
139+
public void ForJsonSchema_ComplexType_Succeeds(bool generic, string? name, string? description)
114140
{
115-
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<SomeType>(TestJsonSerializerContext.Default.Options, name, description);
141+
ChatResponseFormatJson format = generic ?
142+
ChatResponseFormat.ForJsonSchema<SomeType>(TestJsonSerializerContext.Default.Options, name, description) :
143+
ChatResponseFormat.ForJsonSchema(typeof(SomeType), TestJsonSerializerContext.Default.Options, name, description);
144+
116145
Assert.NotNull(format);
117146
Assert.Equal(
118147
"""
@@ -132,12 +161,7 @@ public void ForJsonSchema_ComplexType_Succeeds(string? name, string? description
132161
"null"
133162
]
134163
}
135-
},
136-
"additionalProperties": false,
137-
"required": [
138-
"someInteger",
139-
"someString"
140-
]
164+
}
141165
}
142166
""",
143167
JsonSerializer.Serialize(format.Schema, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))));

test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -37,34 +37,28 @@ public async Task SuccessUsage_Default()
3737
Assert.NotNull(responseFormat.Schema);
3838
AssertDeepEquals(JsonDocument.Parse("""
3939
{
40-
"$schema": "https://json-schema.org/draft/2020-12/schema",
41-
"description": "Some test description",
42-
"type": "object",
43-
"properties": {
44-
"id": {
45-
"type": "integer"
46-
},
47-
"fullName": {
48-
"type": [
49-
"string",
50-
"null"
51-
]
52-
},
53-
"species": {
54-
"type": "string",
55-
"enum": [
56-
"Bear",
57-
"Tiger",
58-
"Walrus"
59-
]
40+
"$schema": "https://json-schema.org/draft/2020-12/schema",
41+
"description": "Some test description",
42+
"type": "object",
43+
"properties": {
44+
"id": {
45+
"type": "integer"
46+
},
47+
"fullName": {
48+
"type": [
49+
"string",
50+
"null"
51+
]
52+
},
53+
"species": {
54+
"type": "string",
55+
"enum": [
56+
"Bear",
57+
"Tiger",
58+
"Walrus"
59+
]
60+
}
6061
}
61-
},
62-
"additionalProperties": false,
63-
"required": [
64-
"id",
65-
"fullName",
66-
"species"
67-
]
6862
}
6963
""").RootElement, responseFormat.Schema.Value);
7064
Assert.Equal(nameof(Animal), responseFormat.SchemaName);
@@ -380,29 +374,23 @@ public async Task CanSpecifyCustomJsonSerializationOptions()
380374
Assert.NotNull(responseFormat.Schema);
381375
AssertDeepEquals(JsonDocument.Parse("""
382376
{
383-
"$schema": "https://json-schema.org/draft/2020-12/schema",
384-
"description": "Some test description",
385-
"type": "object",
386-
"properties": {
387-
"id": {
388-
"type": "integer"
389-
},
390-
"full_name": {
391-
"type": [
392-
"string",
393-
"null"
394-
]
395-
},
396-
"species": {
397-
"type": "integer"
377+
"$schema": "https://json-schema.org/draft/2020-12/schema",
378+
"description": "Some test description",
379+
"type": "object",
380+
"properties": {
381+
"id": {
382+
"type": "integer"
383+
},
384+
"full_name": {
385+
"type": [
386+
"string",
387+
"null"
388+
]
389+
},
390+
"species": {
391+
"type": "integer"
392+
}
398393
}
399-
},
400-
"additionalProperties": false,
401-
"required": [
402-
"id",
403-
"full_name",
404-
"species"
405-
]
406394
}
407395
""").RootElement, responseFormat.Schema.Value);
408396

0 commit comments

Comments
 (0)