Skip to content

Commit 5e18d77

Browse files
committed
Add generated JSON context for Chat model objects including additional properties
1 parent f93b10a commit 5e18d77

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using Microsoft.Extensions.AI;
4+
5+
namespace Devlooped.Extensions.AI;
6+
7+
/// <summary>
8+
/// A JSON converter for <see cref="AdditionalPropertiesDictionary"/> that handles serialization and deserialization
9+
/// of additional properties so they are stored and retrieved as primitive types.
10+
/// </summary>
11+
partial class AdditionalPropertiesDictionaryConverter : JsonConverter<AdditionalPropertiesDictionary>
12+
{
13+
public override AdditionalPropertiesDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
14+
{
15+
if (reader.TokenType != JsonTokenType.StartObject)
16+
throw new JsonException("Expected start of object.");
17+
18+
var dictionary = new AdditionalPropertiesDictionary();
19+
20+
while (reader.Read())
21+
{
22+
if (reader.TokenType == JsonTokenType.EndObject)
23+
return dictionary;
24+
25+
if (reader.TokenType != JsonTokenType.PropertyName)
26+
throw new JsonException("Expected property name.");
27+
28+
var key = reader.GetString()!;
29+
reader.Read();
30+
31+
var value = JsonSerializer.Deserialize<object>(ref reader, options);
32+
if (value is JsonElement element)
33+
dictionary[key] = GetPrimitive(element);
34+
else
35+
dictionary[key] = value;
36+
}
37+
38+
throw new JsonException("Unexpected end of JSON.");
39+
}
40+
41+
public override void Write(Utf8JsonWriter writer, AdditionalPropertiesDictionary value, JsonSerializerOptions options)
42+
{
43+
writer.WriteStartObject();
44+
45+
foreach (var kvp in value.Where(x => x.Value is not null))
46+
{
47+
writer.WritePropertyName(kvp.Key);
48+
JsonSerializer.Serialize(writer, kvp.Value, options);
49+
}
50+
51+
writer.WriteEndObject();
52+
}
53+
54+
// Helper to convert JsonElement to closest .NET primitive
55+
static object? GetPrimitive(JsonElement element)
56+
{
57+
switch (element.ValueKind)
58+
{
59+
case JsonValueKind.String: return element.GetString();
60+
case JsonValueKind.Number:
61+
if (element.TryGetInt32(out var i)) return i;
62+
if (element.TryGetInt64(out var l)) return l;
63+
if (element.TryGetDouble(out var d)) return d;
64+
return element.GetDecimal();
65+
case JsonValueKind.True: return true;
66+
case JsonValueKind.False: return false;
67+
case JsonValueKind.Null: return null;
68+
case JsonValueKind.Object: return element; // You can recurse here if needed
69+
case JsonValueKind.Array: return element; // Or parse as List<object?>
70+
default: return element;
71+
}
72+
}
73+
}

src/AI/ChatJsonContext.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System.Diagnostics;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Text.Encodings.Web;
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
using System.Text.Json.Serialization.Metadata;
7+
using Microsoft.Extensions.AI;
8+
9+
namespace Devlooped.Extensions.AI;
10+
11+
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
12+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | JsonIgnoreCondition.WhenWritingDefault,
13+
UseStringEnumConverter = true,
14+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
15+
PropertyNameCaseInsensitive = true,
16+
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower
17+
#if DEBUG
18+
, WriteIndented = true
19+
#endif
20+
)]
21+
[JsonSerializable(typeof(Chat))]
22+
[JsonSerializable(typeof(ChatResponse))]
23+
[JsonSerializable(typeof(ChatMessage))]
24+
[JsonSerializable(typeof(AdditionalPropertiesDictionary))]
25+
public partial class ChatJsonContext : JsonSerializerContext
26+
{
27+
static readonly Lazy<JsonSerializerOptions> options = new(CreateDefaultOptions);
28+
29+
/// <summary>
30+
/// Provides a pre-configured instance of <see cref="JsonSerializerOptions"/> that aligns with the context's settings.
31+
/// </summary>
32+
public static JsonSerializerOptions DefaultOptions { get => options.Value; }
33+
34+
[UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")]
35+
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")]
36+
static JsonSerializerOptions CreateDefaultOptions()
37+
{
38+
JsonSerializerOptions options = new(Default.Options)
39+
{
40+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
41+
WriteIndented = Debugger.IsAttached,
42+
};
43+
44+
if (JsonSerializer.IsReflectionEnabledByDefault)
45+
{
46+
// If reflection-based serialization is enabled by default, use it as a fallback for all other types.
47+
// Also turn on string-based enum serialization for all unknown enums.
48+
options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver());
49+
options.Converters.Add(new JsonStringEnumConverter());
50+
}
51+
52+
// Make sure we deserialize AdditionalProperties values to their primitive types
53+
options.Converters.Insert(0, new AdditionalPropertiesDictionaryConverter());
54+
55+
options.MakeReadOnly();
56+
return options;
57+
}
58+
}

0 commit comments

Comments
 (0)