Skip to content

Commit 86a5bd4

Browse files
stephentoubjeffhandley
authored andcommitted
Special-case AIContent returned from AIFunctionFactory.Create AIFunctions to not be serialized (dotnet#6935)
* Special-case AIContent returned from AIFunctionFactory.Create AIFunctions to not be serialized They'll likely end up being serialized, anyway, by leaf IChatClients, but this gives those IChatClients the opportunity to do something different, such as by treating DataContent differently. * Update XML comments
1 parent 13a2f7b commit 86a5bd4

File tree

6 files changed

+239
-59
lines changed

6 files changed

+239
-59
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ public static partial class AIFunctionFactory
9999
/// <para>
100100
/// By default, return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
101101
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/> if provided, or else using <see cref="AIJsonUtilities.DefaultOptions"/>.
102+
/// However, return values whose declared type is <see cref="AIContent"/>, a derived type of <see cref="AIContent"/>, or
103+
/// any type assignable from <see cref="IEnumerable{AIContent}"/> (e.g. <c>AIContent[]</c>, <c>List&lt;AIContent&gt;</c>) are
104+
/// special-cased and are not serialized: the created function returns the original instance(s) directly to enable
105+
/// callers (such as an <c>IChatClient</c>) to perform type tests and implement specialized handling. If
106+
/// <see cref="AIFunctionFactoryOptions.MarshalResult"/> is supplied, that delegate governs the behavior instead.
102107
/// Handling of return values can be overridden via <see cref="AIFunctionFactoryOptions.MarshalResult"/>.
103108
/// </para>
104109
/// </remarks>
@@ -172,7 +177,9 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
172177
/// </para>
173178
/// <para>
174179
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="serializerOptions"/> if provided,
175-
/// or else using <see cref="AIJsonUtilities.DefaultOptions"/>.
180+
/// or else using <see cref="AIJsonUtilities.DefaultOptions"/>. However, return values whose declared type is <see cref="AIContent"/>, a
181+
/// derived type of <see cref="AIContent"/>, or any type assignable from <see cref="IEnumerable{AIContent}"/> are not serialized;
182+
/// they are returned as-is to facilitate specialized handling.
176183
/// </para>
177184
/// </remarks>
178185
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
@@ -262,6 +269,8 @@ public static AIFunction Create(Delegate method, string? name = null, string? de
262269
/// <para>
263270
/// By default, return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
264271
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/> if provided, or else using <see cref="AIJsonUtilities.DefaultOptions"/>.
272+
/// However, return values whose declared type is <see cref="AIContent"/>, a derived type of <see cref="AIContent"/>, or
273+
/// any type assignable from <see cref="IEnumerable{AIContent}"/> are not serialized and are instead returned directly.
265274
/// Handling of return values can be overridden via <see cref="AIFunctionFactoryOptions.MarshalResult"/>.
266275
/// </para>
267276
/// </remarks>
@@ -345,7 +354,9 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
345354
/// </para>
346355
/// <para>
347356
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="serializerOptions"/> if provided,
348-
/// or else using <see cref="AIJsonUtilities.DefaultOptions"/>.
357+
/// or else using <see cref="AIJsonUtilities.DefaultOptions"/>. However, return values whose declared type is <see cref="AIContent"/>, a
358+
/// derived type of <see cref="AIContent"/>, or any type assignable from <see cref="IEnumerable{AIContent}"/> are returned
359+
/// without serialization to enable specialized handling.
349360
/// </para>
350361
/// </remarks>
351362
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
@@ -448,6 +459,8 @@ public static AIFunction Create(MethodInfo method, object? target, string? name
448459
/// <para>
449460
/// By default, return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
450461
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/> if provided, or else using <see cref="AIJsonUtilities.DefaultOptions"/>.
462+
/// However, return values whose declared type is <see cref="AIContent"/>, a derived type of <see cref="AIContent"/>, or any type
463+
/// assignable from <see cref="IEnumerable{AIContent}"/> are returned directly without serialization.
451464
/// Handling of return values can be overridden via <see cref="AIFunctionFactoryOptions.MarshalResult"/>.
452465
/// </para>
453466
/// </remarks>
@@ -720,7 +733,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
720733
Description = key.Description ?? key.Method.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description ?? string.Empty;
721734
JsonSerializerOptions = serializerOptions;
722735
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
723-
returnType,
736+
NormalizeReturnType(returnType, serializerOptions),
724737
description: key.Method.ReturnParameter.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description,
725738
serializerOptions: serializerOptions,
726739
inferenceOptions: schemaOptions);
@@ -978,6 +991,7 @@ static void ThrowNullServices(string parameterName) =>
978991
MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult);
979992
returnType = taskResultGetter.ReturnType;
980993

994+
// If a MarshalResult delegate is provided, use it.
981995
if (marshalResult is not null)
982996
{
983997
return async (taskObj, cancellationToken) =>
@@ -988,6 +1002,18 @@ static void ThrowNullServices(string parameterName) =>
9881002
};
9891003
}
9901004

1005+
// Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them
1006+
// specially, such as by returning content to the model/service in a manner appropriate to the content type.
1007+
if (IsAIContentRelatedType(returnType))
1008+
{
1009+
return async (taskObj, cancellationToken) =>
1010+
{
1011+
await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(true);
1012+
return ReflectionInvoke(taskResultGetter, taskObj, null);
1013+
};
1014+
}
1015+
1016+
// For everything else, just serialize the result as-is.
9911017
returnTypeInfo = serializerOptions.GetTypeInfo(returnType);
9921018
return async (taskObj, cancellationToken) =>
9931019
{
@@ -1004,6 +1030,7 @@ static void ThrowNullServices(string parameterName) =>
10041030
MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult);
10051031
returnType = asTaskResultGetter.ReturnType;
10061032

1033+
// If a MarshalResult delegate is provided, use it.
10071034
if (marshalResult is not null)
10081035
{
10091036
return async (taskObj, cancellationToken) =>
@@ -1015,6 +1042,19 @@ static void ThrowNullServices(string parameterName) =>
10151042
};
10161043
}
10171044

1045+
// Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them
1046+
// specially, such as by returning content to the model/service in a manner appropriate to the content type.
1047+
if (IsAIContentRelatedType(returnType))
1048+
{
1049+
return async (taskObj, cancellationToken) =>
1050+
{
1051+
var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!;
1052+
await task.ConfigureAwait(true);
1053+
return ReflectionInvoke(asTaskResultGetter, task, null);
1054+
};
1055+
}
1056+
1057+
// For everything else, just serialize the result as-is.
10181058
returnTypeInfo = serializerOptions.GetTypeInfo(returnType);
10191059
return async (taskObj, cancellationToken) =>
10201060
{
@@ -1026,13 +1066,21 @@ static void ThrowNullServices(string parameterName) =>
10261066
}
10271067
}
10281068

1029-
// For everything else, just serialize the result as-is.
1069+
// If a MarshalResult delegate is provided, use it.
10301070
if (marshalResult is not null)
10311071
{
10321072
Type returnTypeCopy = returnType;
10331073
return (result, cancellationToken) => marshalResult(result, returnTypeCopy, cancellationToken);
10341074
}
10351075

1076+
// Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them
1077+
// specially, such as by returning content to the model/service in a manner appropriate to the content type.
1078+
if (IsAIContentRelatedType(returnType))
1079+
{
1080+
return static (result, _) => new ValueTask<object?>(result);
1081+
}
1082+
1083+
// For everything else, just serialize the result as-is.
10361084
returnTypeInfo = serializerOptions.GetTypeInfo(returnType);
10371085
return (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken);
10381086

@@ -1069,6 +1117,41 @@ private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedT
10691117
#endif
10701118
}
10711119

1120+
private static bool IsAIContentRelatedType(Type type) =>
1121+
typeof(AIContent).IsAssignableFrom(type) ||
1122+
typeof(IEnumerable<AIContent>).IsAssignableFrom(type);
1123+
1124+
private static Type NormalizeReturnType(Type type, JsonSerializerOptions? options)
1125+
{
1126+
options ??= AIJsonUtilities.DefaultOptions;
1127+
1128+
if (options == AIJsonUtilities.DefaultOptions && !options.TryGetTypeInfo(type, out _))
1129+
{
1130+
// GetTypeInfo is not polymorphic, so attempts to look up derived types will fail even if the
1131+
// base type is registered. In some cases, though, we can fall back to using interfaces
1132+
// we know we have contracts for in AIJsonUtilities.DefaultOptions where the semantics of using
1133+
// that interface will be reasonable. This should really only affect situations where
1134+
// reflection-based serialization is disabled.
1135+
1136+
if (typeof(IEnumerable<AIContent>).IsAssignableFrom(type))
1137+
{
1138+
return typeof(IEnumerable<AIContent>);
1139+
}
1140+
1141+
if (typeof(IEnumerable<ChatMessage>).IsAssignableFrom(type))
1142+
{
1143+
return typeof(IEnumerable<ChatMessage>);
1144+
}
1145+
1146+
if (typeof(IEnumerable<string>).IsAssignableFrom(type))
1147+
{
1148+
return typeof(IEnumerable<string>);
1149+
}
1150+
}
1151+
1152+
return type;
1153+
}
1154+
10721155
private record struct DescriptorKey(
10731156
MethodInfo Method,
10741157
string? Name,

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

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -75,39 +75,23 @@ private static JsonSerializerOptions CreateDefaultOptions()
7575
UseStringEnumConverter = true,
7676
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
7777
WriteIndented = true)]
78-
[JsonSerializable(typeof(SpeechToTextOptions))]
79-
[JsonSerializable(typeof(SpeechToTextClientMetadata))]
80-
[JsonSerializable(typeof(SpeechToTextResponse))]
81-
[JsonSerializable(typeof(SpeechToTextResponseUpdate))]
82-
[JsonSerializable(typeof(IReadOnlyList<SpeechToTextResponseUpdate>))]
83-
[JsonSerializable(typeof(ImageGenerationOptions))]
84-
[JsonSerializable(typeof(ImageGenerationResponse))]
85-
[JsonSerializable(typeof(IList<ChatMessage>))]
86-
[JsonSerializable(typeof(IEnumerable<ChatMessage>))]
87-
[JsonSerializable(typeof(ChatMessage[]))]
88-
[JsonSerializable(typeof(ChatOptions))]
89-
[JsonSerializable(typeof(EmbeddingGenerationOptions))]
90-
[JsonSerializable(typeof(ChatClientMetadata))]
91-
[JsonSerializable(typeof(EmbeddingGeneratorMetadata))]
92-
[JsonSerializable(typeof(ChatResponse))]
93-
[JsonSerializable(typeof(ChatResponseUpdate))]
94-
[JsonSerializable(typeof(IReadOnlyList<ChatResponseUpdate>))]
95-
[JsonSerializable(typeof(Dictionary<string, object>))]
96-
[JsonSerializable(typeof(IDictionary<string, object?>))]
78+
79+
// JSON
9780
[JsonSerializable(typeof(JsonDocument))]
9881
[JsonSerializable(typeof(JsonElement))]
9982
[JsonSerializable(typeof(JsonNode))]
10083
[JsonSerializable(typeof(JsonObject))]
10184
[JsonSerializable(typeof(JsonValue))]
10285
[JsonSerializable(typeof(JsonArray))]
103-
[JsonSerializable(typeof(IEnumerable<string>))]
104-
[JsonSerializable(typeof(char))]
86+
87+
// Primitives
10588
[JsonSerializable(typeof(string))]
106-
[JsonSerializable(typeof(int))]
89+
[JsonSerializable(typeof(char))]
10790
[JsonSerializable(typeof(short))]
108-
[JsonSerializable(typeof(long))]
109-
[JsonSerializable(typeof(uint))]
11091
[JsonSerializable(typeof(ushort))]
92+
[JsonSerializable(typeof(int))]
93+
[JsonSerializable(typeof(uint))]
94+
[JsonSerializable(typeof(long))]
11195
[JsonSerializable(typeof(ulong))]
11296
[JsonSerializable(typeof(float))]
11397
[JsonSerializable(typeof(double))]
@@ -116,26 +100,58 @@ private static JsonSerializerOptions CreateDefaultOptions()
116100
[JsonSerializable(typeof(TimeSpan))]
117101
[JsonSerializable(typeof(DateTime))]
118102
[JsonSerializable(typeof(DateTimeOffset))]
119-
[JsonSerializable(typeof(Embedding))]
120-
[JsonSerializable(typeof(Embedding<byte>))]
121-
[JsonSerializable(typeof(Embedding<int>))]
122-
#if NET
123-
[JsonSerializable(typeof(Embedding<Half>))]
124-
#endif
125-
[JsonSerializable(typeof(Embedding<float>))]
126-
[JsonSerializable(typeof(Embedding<double>))]
127-
[JsonSerializable(typeof(AIContent))]
103+
104+
// AIFunction
128105
[JsonSerializable(typeof(AIFunctionArguments))]
129106

130-
// Temporary workaround:
131-
// These should be added in once they're no longer [Experimental] and included via [JsonDerivedType] on AIContent.
107+
// IChatClient
108+
[JsonSerializable(typeof(IEnumerable<ChatMessage>))]
109+
[JsonSerializable(typeof(IList<ChatMessage>))]
110+
[JsonSerializable(typeof(ChatMessage[]))]
111+
[JsonSerializable(typeof(ChatOptions))]
112+
[JsonSerializable(typeof(ChatClientMetadata))]
113+
[JsonSerializable(typeof(ChatResponse))]
114+
[JsonSerializable(typeof(ChatResponseUpdate))]
115+
[JsonSerializable(typeof(IReadOnlyList<ChatResponseUpdate>))]
116+
[JsonSerializable(typeof(Dictionary<string, object>))]
117+
[JsonSerializable(typeof(IDictionary<string, object?>))]
118+
[JsonSerializable(typeof(IEnumerable<string>))]
119+
[JsonSerializable(typeof(AIContent))]
120+
[JsonSerializable(typeof(IEnumerable<AIContent>))]
121+
122+
// Temporary workaround: These should be implicitly added in once they're no longer [Experimental]
123+
// and are included via [JsonDerivedType] on AIContent.
132124
[JsonSerializable(typeof(FunctionApprovalRequestContent))]
133125
[JsonSerializable(typeof(FunctionApprovalResponseContent))]
134126
[JsonSerializable(typeof(McpServerToolCallContent))]
135127
[JsonSerializable(typeof(McpServerToolResultContent))]
136128
[JsonSerializable(typeof(McpServerToolApprovalRequestContent))]
137129
[JsonSerializable(typeof(McpServerToolApprovalResponseContent))]
138130
[JsonSerializable(typeof(ResponseContinuationToken))]
131+
132+
// IEmbeddingGenerator
133+
[JsonSerializable(typeof(EmbeddingGenerationOptions))]
134+
[JsonSerializable(typeof(EmbeddingGeneratorMetadata))]
135+
[JsonSerializable(typeof(Embedding))]
136+
[JsonSerializable(typeof(Embedding<byte>))]
137+
[JsonSerializable(typeof(Embedding<int>))]
138+
#if NET
139+
[JsonSerializable(typeof(Embedding<Half>))]
140+
#endif
141+
[JsonSerializable(typeof(Embedding<float>))]
142+
[JsonSerializable(typeof(Embedding<double>))]
143+
144+
// ISpeechToTextClient
145+
[JsonSerializable(typeof(SpeechToTextOptions))]
146+
[JsonSerializable(typeof(SpeechToTextClientMetadata))]
147+
[JsonSerializable(typeof(SpeechToTextResponse))]
148+
[JsonSerializable(typeof(SpeechToTextResponseUpdate))]
149+
[JsonSerializable(typeof(IReadOnlyList<SpeechToTextResponseUpdate>))]
150+
151+
// IImageGenerator
152+
[JsonSerializable(typeof(ImageGenerationOptions))]
153+
[JsonSerializable(typeof(ImageGenerationResponse))]
154+
139155
[EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead.
140156
private sealed partial class JsonContext : JsonSerializerContext;
141157

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -760,15 +760,27 @@ internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<Chat
760760

761761
case FunctionResultContent resultContent:
762762
string? result = resultContent.Result as string;
763-
if (result is null && resultContent.Result is not null)
763+
if (result is null && resultContent.Result is { } resultObj)
764764
{
765-
try
765+
switch (resultObj)
766766
{
767-
result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
768-
}
769-
catch (NotSupportedException)
770-
{
771-
// If the type can't be serialized, skip it.
767+
// https://github.com/openai/openai-dotnet/issues/759
768+
// Once OpenAI supports other forms of tool call outputs, special-case various AIContent types here, e.g.
769+
// case DataContent
770+
// case HostedFileContent
771+
// case IEnumerable<AIContent>
772+
// etc.
773+
774+
default:
775+
try
776+
{
777+
result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
778+
}
779+
catch (NotSupportedException)
780+
{
781+
// If the type can't be serialized, skip it.
782+
}
783+
break;
772784
}
773785
}
774786

test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs

Lines changed: 17 additions & 7 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.Diagnostics.CodeAnalysis;
56
using System.Net.Http;
67
using System.Text;
78
using System.Text.Json.Nodes;
@@ -85,12 +86,24 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
8586
return new() { Content = new StringContent(_expectedOutput) };
8687
}
8788

88-
public static string? RemoveWhiteSpace(string? text) =>
89-
text is null ? null :
90-
Regex.Replace(text, @"\s*", string.Empty);
89+
[return: NotNullIfNotNull(nameof(text))]
90+
public static string? RemoveWhiteSpace(string? text)
91+
{
92+
if (text is null)
93+
{
94+
return null;
95+
}
96+
97+
text = text.Replace("\\r", "").Replace("\\n", "").Replace("\\t", "");
98+
99+
return Regex.Replace(text, @"\s*", string.Empty);
100+
}
91101

92102
private static void AssertEqualNormalized(string expected, string actual)
93103
{
104+
expected = RemoveWhiteSpace(expected);
105+
actual = RemoveWhiteSpace(actual);
106+
94107
// First try to compare as JSON.
95108
JsonNode? expectedNode = null;
96109
JsonNode? actualNode = null;
@@ -114,10 +127,7 @@ private static void AssertEqualNormalized(string expected, string actual)
114127
}
115128

116129
// Legitimately may not have been JSON. Fall back to whitespace normalization.
117-
if (RemoveWhiteSpace(expected) != RemoveWhiteSpace(actual))
118-
{
119-
FailNotEqual(expected, actual);
120-
}
130+
FailNotEqual(expected, actual);
121131
}
122132

123133
private static void FailNotEqual(string expected, string actual) =>

0 commit comments

Comments
 (0)