Skip to content

Commit 243ac34

Browse files
CopilotjozkeeCopilot
authored
AddAIContentType automatically registers the content type against every base in the inheritance chain up to AIContent (#7358)
* Initial plan * Make AddAIContentType with baseType parameter public Add two new public overloads of AddAIContentType that accept a baseType parameter, allowing consumers to register AIContent-derived types in custom polymorphic hierarchies (e.g., under ToolCallContent or ToolResultContent, not just AIContent). - Generic: AddAIContentType<TContent>(options, baseType, discriminatorId) - Non-generic: AddAIContentType(options, baseType, contentType, discriminatorId) Updates API baseline and adds comprehensive unit tests. Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com> * Auto-register AddAIContentType across full inheritance chain Instead of adding new baseType overloads, modify the existing AddAIContentType methods to automatically walk the inheritance chain from the content type up to AIContent, registering against every intermediate base type. This eliminates the need for callers to manually register against each level in the hierarchy. The internal registration of experimental types (CodeInterpreter, ImageGeneration) is simplified from 8 calls to 4, since the chain walker handles intermediate bases (ToolCallContent/ToolResultContent) automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CHANGELOG entry for AddAIContentType chain registration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Augment tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com> Co-authored-by: David Cantu <dacantu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 959f658 commit 243ac34

4 files changed

Lines changed: 222 additions & 24 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## NOT YET RELEASED
44

5+
- `AddAIContentType` now automatically registers the content type against every base in the inheritance chain up to `AIContent`.
56
- Added `IHostedFileClient` interface and related types for interacting with files hosted by the service.
67
- Added `WebSearchToolCallContent` and `WebSearchToolResultContent` for representing web search tool calls and results.
78
- Added `ToolCallContent` and `ToolResultContent` base classes.

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

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,12 @@ private static JsonSerializerOptions CreateDefaultOptions()
5151
// Temporary workaround: these types are [Experimental] and can't be added as [JsonDerivedType] on AIContent yet,
5252
// or else consuming assemblies that used source generation with AIContent would implicitly reference them.
5353
// Once they're no longer [Experimental] and added as [JsonDerivedType] on AIContent, these lines should be removed.
54-
AddAIContentType(options, typeof(AIContent), typeof(CodeInterpreterToolCallContent), typeDiscriminatorId: "codeInterpreterToolCall", checkBuiltIn: false);
55-
AddAIContentType(options, typeof(AIContent), typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false);
56-
AddAIContentType(options, typeof(AIContent), typeof(ImageGenerationToolCallContent), typeDiscriminatorId: "imageGenerationToolCall", checkBuiltIn: false);
57-
AddAIContentType(options, typeof(AIContent), typeof(ImageGenerationToolResultContent), typeDiscriminatorId: "imageGenerationToolResult", checkBuiltIn: false);
58-
AddAIContentType(options, typeof(AIContent), typeof(WebSearchToolCallContent), typeDiscriminatorId: "webSearchToolCall", checkBuiltIn: false);
59-
AddAIContentType(options, typeof(AIContent), typeof(WebSearchToolResultContent), typeDiscriminatorId: "webSearchToolResult", checkBuiltIn: false);
60-
61-
// Also register the experimental types as derived types of ToolCallContent/ToolResultContent.
62-
AddAIContentType(options, typeof(ToolCallContent), typeof(CodeInterpreterToolCallContent), "codeInterpreterToolCall", checkBuiltIn: false);
63-
AddAIContentType(options, typeof(ToolCallContent), typeof(ImageGenerationToolCallContent), "imageGenerationToolCall", checkBuiltIn: false);
64-
AddAIContentType(options, typeof(ToolCallContent), typeof(WebSearchToolCallContent), "webSearchToolCall", checkBuiltIn: false);
65-
AddAIContentType(options, typeof(ToolResultContent), typeof(CodeInterpreterToolResultContent), "codeInterpreterToolResult", checkBuiltIn: false);
66-
AddAIContentType(options, typeof(ToolResultContent), typeof(ImageGenerationToolResultContent), "imageGenerationToolResult", checkBuiltIn: false);
67-
AddAIContentType(options, typeof(ToolResultContent), typeof(WebSearchToolResultContent), "webSearchToolResult", checkBuiltIn: false);
54+
AddAIContentTypeChain(options, typeof(CodeInterpreterToolCallContent), typeDiscriminatorId: "codeInterpreterToolCall", checkBuiltIn: false);
55+
AddAIContentTypeChain(options, typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false);
56+
AddAIContentTypeChain(options, typeof(ImageGenerationToolCallContent), typeDiscriminatorId: "imageGenerationToolCall", checkBuiltIn: false);
57+
AddAIContentTypeChain(options, typeof(ImageGenerationToolResultContent), typeDiscriminatorId: "imageGenerationToolResult", checkBuiltIn: false);
58+
AddAIContentTypeChain(options, typeof(WebSearchToolCallContent), typeDiscriminatorId: "webSearchToolCall", checkBuiltIn: false);
59+
AddAIContentTypeChain(options, typeof(WebSearchToolResultContent), typeDiscriminatorId: "webSearchToolResult", checkBuiltIn: false);
6860

6961
if (JsonSerializer.IsReflectionEnabledByDefault)
7062
{

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

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public static void AddAIContentType<TContent>(this JsonSerializerOptions options
3636
_ = Throw.IfNull(options);
3737
_ = Throw.IfNull(typeDiscriminatorId);
3838

39-
AddAIContentType(options, typeof(AIContent), typeof(TContent), typeDiscriminatorId, checkBuiltIn: true);
39+
AddAIContentTypeChain(options, typeof(TContent), typeDiscriminatorId, checkBuiltIn: true);
4040
}
4141

4242
/// <summary>
@@ -46,7 +46,7 @@ public static void AddAIContentType<TContent>(this JsonSerializerOptions options
4646
/// <param name="contentType">The custom content type to configure.</param>
4747
/// <param name="typeDiscriminatorId">The type discriminator id for the content type.</param>
4848
/// <exception cref="ArgumentNullException"><paramref name="options"/>, <paramref name="contentType"/>, or <paramref name="typeDiscriminatorId"/> is <see langword="null"/>.</exception>
49-
/// <exception cref="ArgumentException"><paramref name="contentType"/> is a built-in content type or does not derived from <see cref="AIContent"/>.</exception>
49+
/// <exception cref="ArgumentException"><paramref name="contentType"/> is a built-in content type or does not derive from <see cref="AIContent"/>.</exception>
5050
/// <exception cref="InvalidOperationException"><paramref name="options"/> is a read-only instance.</exception>
5151
public static void AddAIContentType(this JsonSerializerOptions options, Type contentType, string typeDiscriminatorId)
5252
{
@@ -59,7 +59,7 @@ public static void AddAIContentType(this JsonSerializerOptions options, Type con
5959
Throw.ArgumentException(nameof(contentType), $"The content type must derive from {nameof(AIContent)}.");
6060
}
6161

62-
AddAIContentType(options, typeof(AIContent), contentType, typeDiscriminatorId, checkBuiltIn: true);
62+
AddAIContentTypeChain(options, contentType, typeDiscriminatorId, checkBuiltIn: true);
6363
}
6464

6565
/// <summary>Serializes the supplied values and computes a string hash of the resulting JSON.</summary>
@@ -186,21 +186,35 @@ static void NormalizeJsonNode(JsonNode? node)
186186
}
187187
}
188188

189-
private static void AddAIContentType(JsonSerializerOptions options, Type baseType, Type contentType, string typeDiscriminatorId, bool checkBuiltIn)
189+
/// <summary>
190+
/// Walks the inheritance chain from <paramref name="contentType"/> up to and including <see cref="AIContent"/>,
191+
/// registering the content type as a derived type of each base in the chain.
192+
/// </summary>
193+
private static void AddAIContentTypeChain(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId, bool checkBuiltIn)
190194
{
191195
if (checkBuiltIn && (contentType.Assembly == typeof(AIContent).Assembly))
192196
{
193197
Throw.ArgumentException(nameof(contentType), $"Cannot register built-in {nameof(AIContent)} types.");
194198
}
195199

196-
IJsonTypeInfoResolver resolver = options.TypeInfoResolver ?? DefaultOptions.TypeInfoResolver!;
197-
options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
200+
for (Type? baseType = contentType.BaseType;
201+
baseType is not null && typeof(AIContent).IsAssignableFrom(baseType);
202+
baseType = baseType.BaseType)
198203
{
199-
if (typeInfo.Type == baseType)
204+
AddDerivedAIContentType(options, baseType, contentType, typeDiscriminatorId);
205+
}
206+
207+
static void AddDerivedAIContentType(JsonSerializerOptions options, Type baseType, Type contentType, string typeDiscriminatorId)
208+
{
209+
IJsonTypeInfoResolver resolver = options.TypeInfoResolver ?? DefaultOptions.TypeInfoResolver!;
210+
options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
200211
{
201-
(typeInfo.PolymorphismOptions ??= new()).DerivedTypes.Add(new(contentType, typeDiscriminatorId));
202-
}
203-
});
212+
if (typeInfo.Type == baseType)
213+
{
214+
(typeInfo.PolymorphismOptions ??= new()).DerivedTypes.Add(new(contentType, typeDiscriminatorId));
215+
}
216+
});
217+
}
204218
}
205219

206220
#if NET

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,180 @@ public static void AddAIContentType_NullArguments_ThrowsArgumentNullException()
12231223
Assert.Throws<ArgumentNullException>("contentType", () => options.AddAIContentType(null!, "discriminator"));
12241224
}
12251225

1226+
[Theory]
1227+
[InlineData(true)]
1228+
[InlineData(false)]
1229+
public static void AddAIContentType_LeafContent_RegistersInChain(bool useGenericOverload)
1230+
{
1231+
JsonSerializerOptions options = new()
1232+
{
1233+
TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default),
1234+
};
1235+
1236+
if (useGenericOverload)
1237+
{
1238+
options.AddAIContentType<LeafContent>("leaf");
1239+
}
1240+
else
1241+
{
1242+
options.AddAIContentType(typeof(LeafContent), "leaf");
1243+
}
1244+
1245+
// Verify LeafContent is registered as a derived type of both DerivedAIContent and AIContent.
1246+
var derivedAIContentTypes = options.GetTypeInfo(typeof(DerivedAIContent)).PolymorphismOptions!.DerivedTypes;
1247+
var aiContentTypes = options.GetTypeInfo(typeof(AIContent)).PolymorphismOptions!.DerivedTypes;
1248+
Assert.Contains(derivedAIContentTypes, dt => dt.DerivedType == typeof(LeafContent));
1249+
Assert.Contains(aiContentTypes, dt => dt.DerivedType == typeof(LeafContent));
1250+
1251+
// Verify serialization/deserialization works when typed as DerivedAIContent.
1252+
DerivedAIContent dc = new LeafContent { DerivedValue = 1, LeafValue = 42 };
1253+
string json = JsonSerializer.Serialize(dc, options);
1254+
Assert.Contains("\"$type\":\"leaf\"", json);
1255+
Assert.Contains("\"DerivedValue\":1", json);
1256+
Assert.Contains("\"LeafValue\":42", json);
1257+
1258+
LeafContent deserializedDc = Assert.IsType<LeafContent>(JsonSerializer.Deserialize<DerivedAIContent>(json, options));
1259+
Assert.Equal(1, deserializedDc.DerivedValue);
1260+
Assert.Equal(42, deserializedDc.LeafValue);
1261+
1262+
// Verify serialization/deserialization works when typed as AIContent.
1263+
AIContent ac = new LeafContent { DerivedValue = 2, LeafValue = 99 };
1264+
string json2 = JsonSerializer.Serialize(ac, options);
1265+
Assert.Contains("\"$type\":\"leaf\"", json2);
1266+
Assert.Contains("\"DerivedValue\":2", json2);
1267+
Assert.Contains("\"LeafValue\":99", json2);
1268+
1269+
LeafContent deserializedAc = Assert.IsType<LeafContent>(JsonSerializer.Deserialize<AIContent>(json2, options));
1270+
Assert.Equal(2, deserializedAc.DerivedValue);
1271+
Assert.Equal(99, deserializedAc.LeafValue);
1272+
}
1273+
1274+
[Theory]
1275+
[InlineData(true)]
1276+
[InlineData(false)]
1277+
public static void AddAIContentType_ChainWalksThroughBuiltInBaseTypes(bool useGenericOverload)
1278+
{
1279+
// Verifies that the chain registration walks through built-in intermediate types.
1280+
// For DerivedToolCallContent : ToolCallContent : AIContent, a single call should
1281+
// register against both ToolCallContent (built-in) and AIContent (built-in), so that
1282+
// deserialization works regardless of which base type the variable is declared as.
1283+
JsonSerializerOptions options = new()
1284+
{
1285+
TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default),
1286+
};
1287+
1288+
if (useGenericOverload)
1289+
{
1290+
options.AddAIContentType<DerivedToolCallContent>("derivedToolCall");
1291+
}
1292+
else
1293+
{
1294+
options.AddAIContentType(typeof(DerivedToolCallContent), "derivedToolCall");
1295+
}
1296+
1297+
// Verify DerivedToolCallContent is registered as a derived type of both ToolCallContent and AIContent.
1298+
var toolCallTypes = options.GetTypeInfo(typeof(ToolCallContent)).PolymorphismOptions!.DerivedTypes;
1299+
var aiContentTypes = options.GetTypeInfo(typeof(AIContent)).PolymorphismOptions!.DerivedTypes;
1300+
Assert.Contains(toolCallTypes, dt => dt.DerivedType == typeof(DerivedToolCallContent));
1301+
Assert.Contains(aiContentTypes, dt => dt.DerivedType == typeof(DerivedToolCallContent));
1302+
1303+
// Verify roundtrip when typed as ToolCallContent (built-in intermediate base).
1304+
ToolCallContent tc = new DerivedToolCallContent { CustomValue = 42 };
1305+
string json = JsonSerializer.Serialize(tc, options);
1306+
Assert.Contains("\"$type\":\"derivedToolCall\"", json);
1307+
Assert.Contains("\"CustomValue\":42", json);
1308+
1309+
DerivedToolCallContent deserializedTc = Assert.IsType<DerivedToolCallContent>(JsonSerializer.Deserialize<ToolCallContent>(json, options));
1310+
Assert.Equal(42, deserializedTc.CustomValue);
1311+
1312+
// Verify roundtrip when typed as AIContent (root base).
1313+
AIContent ac = new DerivedToolCallContent { CustomValue = 99 };
1314+
string json2 = JsonSerializer.Serialize(ac, options);
1315+
Assert.Contains("\"$type\":\"derivedToolCall\"", json2);
1316+
Assert.Contains("\"CustomValue\":99", json2);
1317+
1318+
DerivedToolCallContent deserializedAc = Assert.IsType<DerivedToolCallContent>(JsonSerializer.Deserialize<AIContent>(json2, options));
1319+
Assert.Equal(99, deserializedAc.CustomValue);
1320+
}
1321+
1322+
[Fact]
1323+
public static void AddAIContentType_OverlappingChains_RegistersBothCorrectly()
1324+
{
1325+
// Registers DerivedAIContent and LeafContent (which derives from DerivedAIContent).
1326+
// Both types should coexist: DerivedAIContent is registered under AIContent,
1327+
// and LeafContent is registered under both DerivedAIContent and AIContent.
1328+
JsonSerializerOptions options = new()
1329+
{
1330+
TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default),
1331+
};
1332+
1333+
options.AddAIContentType<DerivedAIContent>("derived");
1334+
options.AddAIContentType<LeafContent>("leaf");
1335+
1336+
// Verify registrations: AIContent should have both derived and leaf; DerivedAIContent should have leaf.
1337+
var aiContentDerivedTypes = options.GetTypeInfo(typeof(AIContent)).PolymorphismOptions!.DerivedTypes;
1338+
var derivedAIContentTypes = options.GetTypeInfo(typeof(DerivedAIContent)).PolymorphismOptions!.DerivedTypes;
1339+
Assert.Contains(aiContentDerivedTypes, dt => dt.DerivedType == typeof(DerivedAIContent));
1340+
Assert.Contains(aiContentDerivedTypes, dt => dt.DerivedType == typeof(LeafContent));
1341+
Assert.Contains(derivedAIContentTypes, dt => dt.DerivedType == typeof(LeafContent));
1342+
1343+
// DerivedAIContent roundtrips when typed as AIContent.
1344+
AIContent ac = new DerivedAIContent { DerivedValue = 1 };
1345+
string json = JsonSerializer.Serialize(ac, options);
1346+
Assert.Contains("\"$type\":\"derived\"", json);
1347+
DerivedAIContent deserializedDc = Assert.IsType<DerivedAIContent>(JsonSerializer.Deserialize<AIContent>(json, options));
1348+
Assert.Equal(1, deserializedDc.DerivedValue);
1349+
1350+
// LeafContent roundtrips when typed as DerivedAIContent.
1351+
DerivedAIContent dc = new LeafContent { DerivedValue = 2, LeafValue = 42 };
1352+
string json2 = JsonSerializer.Serialize(dc, options);
1353+
Assert.Contains("\"$type\":\"leaf\"", json2);
1354+
LeafContent deserializedLeaf = Assert.IsType<LeafContent>(JsonSerializer.Deserialize<DerivedAIContent>(json2, options));
1355+
Assert.Equal(2, deserializedLeaf.DerivedValue);
1356+
Assert.Equal(42, deserializedLeaf.LeafValue);
1357+
1358+
// LeafContent roundtrips when typed as AIContent.
1359+
AIContent ac2 = new LeafContent { DerivedValue = 3, LeafValue = 99 };
1360+
string json3 = JsonSerializer.Serialize(ac2, options);
1361+
Assert.Contains("\"$type\":\"leaf\"", json3);
1362+
LeafContent deserializedAc = Assert.IsType<LeafContent>(JsonSerializer.Deserialize<AIContent>(json3, options));
1363+
Assert.Equal(3, deserializedAc.DerivedValue);
1364+
Assert.Equal(99, deserializedAc.LeafValue);
1365+
}
1366+
1367+
[Fact]
1368+
public static void AddAIContentType_DoesNotRegisterIntermediateTypes()
1369+
{
1370+
// Registering LeafContent should NOT automatically register DerivedAIContent.
1371+
// Only the explicitly provided type gets a discriminator. This is by design:
1372+
// auto-registering intermediates would make it impossible to register multiple
1373+
// leaf types sharing a common base (the intermediate would be registered twice
1374+
// with no discriminator, or a conflicting one).
1375+
JsonSerializerOptions options = new()
1376+
{
1377+
TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default),
1378+
};
1379+
1380+
options.AddAIContentType<LeafContent>("leaf");
1381+
1382+
// Verify LeafContent is registered under both bases, but DerivedAIContent is NOT.
1383+
var aiContentDerivedTypes = options.GetTypeInfo(typeof(AIContent)).PolymorphismOptions!.DerivedTypes;
1384+
var derivedAIContentTypes = options.GetTypeInfo(typeof(DerivedAIContent)).PolymorphismOptions!.DerivedTypes;
1385+
Assert.Contains(aiContentDerivedTypes, dt => dt.DerivedType == typeof(LeafContent));
1386+
Assert.DoesNotContain(aiContentDerivedTypes, dt => dt.DerivedType == typeof(DerivedAIContent));
1387+
Assert.Contains(derivedAIContentTypes, dt => dt.DerivedType == typeof(LeafContent));
1388+
1389+
// LeafContent roundtrips fine when typed as AIContent.
1390+
AIContent ac = new LeafContent { DerivedValue = 1, LeafValue = 42 };
1391+
string json = JsonSerializer.Serialize(ac, options);
1392+
Assert.Contains("\"$type\":\"leaf\"", json);
1393+
Assert.IsType<LeafContent>(JsonSerializer.Deserialize<AIContent>(json, options));
1394+
1395+
// But a plain DerivedAIContent instance is NOT known to AIContent — no discriminator was registered for it.
1396+
AIContent unregistered = new DerivedAIContent { DerivedValue = 99 };
1397+
Assert.Throws<NotSupportedException>(() => JsonSerializer.Serialize(unregistered, options));
1398+
}
1399+
12261400
[Fact]
12271401
public static void HashData_Idempotent()
12281402
{
@@ -1668,14 +1842,31 @@ public static void TransformJsonSchema_BooleanSchemas_Success(string booleanSche
16681842
}
16691843
}
16701844

1845+
private class LeafContent : DerivedAIContent
1846+
{
1847+
public int LeafValue { get; set; }
1848+
}
1849+
16711850
private class DerivedAIContent : AIContent
16721851
{
16731852
public int DerivedValue { get; set; }
16741853
}
16751854

1855+
private class DerivedToolCallContent : ToolCallContent
1856+
{
1857+
public DerivedToolCallContent()
1858+
: base("callId")
1859+
{
1860+
}
1861+
1862+
public int CustomValue { get; set; }
1863+
}
1864+
16761865
[JsonSerializable(typeof(JsonElement))]
16771866
[JsonSerializable(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type))]
16781867
[JsonSerializable(typeof(DerivedAIContent))]
1868+
[JsonSerializable(typeof(DerivedToolCallContent))]
1869+
[JsonSerializable(typeof(LeafContent))]
16791870
[JsonSerializable(typeof(MyPoco))]
16801871
[JsonSerializable(typeof(MyEnumValue?))]
16811872
[JsonSerializable(typeof(object[]))]

0 commit comments

Comments
 (0)