Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using CrestApps.Core.Models;
using CrestApps.Core.Services;
Expand Down Expand Up @@ -149,7 +148,8 @@ private string _utilityDeploymentIdBackingField
/// <summary>
/// Gets or sets the JSON-based settings for the profile.
/// </summary>
public JsonObject Settings { get; init; } = [];
[JsonConverter(typeof(JsonExtensionDataConverter))]
public IDictionary<string, object> Settings { get; set; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Gets legacy connection name.
Expand Down Expand Up @@ -189,8 +189,8 @@ public AIProfile Clone()
CreatedUtc = CreatedUtc,
OwnerId = OwnerId,
Author = Author,
Properties = Properties?.Clone(),
Settings = Settings?.Clone(),
Properties = Properties?.Clone() ?? new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase),
Settings = Settings?.Clone() ?? new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,16 @@ public static T GetSettings<T>(this AIProfile profile, JsonSerializerOptions jso
public static T GetOrCreateSettings<T>(this AIProfile profile, JsonSerializerOptions jsonSerializerOptions = null)
where T : new()
{
if (profile.Settings == null)
{
return new T();
}
ArgumentNullException.ThrowIfNull(profile);

var node = profile.Settings[typeof(T).Name];

if (node == null)
if (profile.Settings == null)
{
return new T();
}

return node.Deserialize<T>(jsonSerializerOptions ?? _jsonOptions) ?? new T();
return profile.Settings.TryGetValue(typeof(T).Name, out var value)
? DeserializeValue<T>(value, jsonSerializerOptions ?? _jsonOptions) ?? new T()
: new T();
}

/// <summary>
Expand All @@ -50,25 +47,25 @@ public static T GetOrCreateSettings<T>(this AIProfile profile, JsonSerializerOpt
public static bool TryGetSettings<T>(this AIProfile profile, out T settings, JsonSerializerOptions jsonSerializerOptions = null)
where T : class
{
ArgumentNullException.ThrowIfNull(profile);

if (profile.Settings == null)
{
settings = null;

return false;
}

var node = profile.Settings[typeof(T).Name];

if (node == null)
if (!profile.Settings.TryGetValue(typeof(T).Name, out var value) || value == null)
{
settings = null;

return false;
}

settings = node.Deserialize<T>(jsonSerializerOptions ?? _jsonOptions);
settings = DeserializeValue<T>(value, jsonSerializerOptions ?? _jsonOptions);

return true;
return settings != null;
}

/// <summary>
Expand All @@ -80,16 +77,10 @@ public static bool TryGetSettings<T>(this AIProfile profile, out T settings, Jso
public static AIProfile AlterSettings<T>(this AIProfile profile, Action<T> setting, JsonSerializerOptions jsonSerializerOptions = null)
where T : class, new()
{
var existingJObject = profile.Settings[typeof(T).Name] as JsonObject;
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(setting);

if (existingJObject == null)
{
existingJObject = JsonExtensions.FromObject(new T(), jsonSerializerOptions ?? _jsonOptions);

profile.Settings[typeof(T).Name] = existingJObject;
}

var settingsToMerge = existingJObject.Deserialize<T>(jsonSerializerOptions ?? _jsonOptions);
var settingsToMerge = profile.GetOrCreateSettings<T>(jsonSerializerOptions ?? _jsonOptions);

setting(settingsToMerge);

Expand All @@ -103,6 +94,7 @@ public static AIProfile AlterSettings<T>(this AIProfile profile, Action<T> setti
/// </summary>
public static AIProfile WithSettings<T>(this AIProfile profile, T settings, JsonSerializerOptions jsonSerializerOptions = null)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(settings);

var jObject = JsonExtensions.FromObject(settings, jsonSerializerOptions ?? _jsonOptions);
Expand All @@ -111,4 +103,36 @@ public static AIProfile WithSettings<T>(this AIProfile profile, T settings, Json

return profile;
}

private static T DeserializeValue<T>(object value, JsonSerializerOptions jsonSerializerOptions = null)
{
if (value is null)
{
return default;
}

if (value is T typed)
{
return typed;
}

if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Null)
{
return default;
}

return jsonElement.Deserialize<T>(jsonSerializerOptions ?? _jsonOptions);
}

if (value is JsonNode jsonNode)
{
return jsonNode.Deserialize<T>(jsonSerializerOptions ?? _jsonOptions);
}

var json = JsonSerializer.Serialize(value, jsonSerializerOptions ?? _jsonOptions);

return JsonSerializer.Deserialize<T>(json, jsonSerializerOptions ?? _jsonOptions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ public abstract class ExtensibleEntity
/// Gets or sets the dictionary of additional properties stored under the
/// <c>Properties</c> JSON object.
/// </summary>
[JsonConverter(typeof(ExtensibleEntityPropertiesJsonConverter))]
[JsonConverter(typeof(JsonExtensionDataConverter))]
public IDictionary<string, object> Properties { get; set; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
Expand All @@ -10,7 +8,7 @@ namespace CrestApps.Core;
/// Serializes and deserializes <see cref="ExtensibleEntity.Properties"/> as a normal nested
/// JSON object while keeping its values inside the property bag.
/// </summary>
internal sealed class ExtensibleEntityPropertiesJsonConverter : JsonConverter<IDictionary<string, object>>
public sealed class JsonExtensionDataConverter : JsonConverter<IDictionary<string, object>>
{
/// <summary>
/// Reads the property bag from a nested JSON object.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using CrestApps.Core.AI.Completions;
using CrestApps.Core.AI.Models;
using CrestApps.Core.Templates.Services;
Expand Down Expand Up @@ -53,9 +54,16 @@ public async Task BuildingAsync(AICompletionContextBuildingContext context)
context.Context.ToolNames = functionInvocationMetadata.Names;
}

if (context.Context.ToolNames is not { Length: > 0 } && profile.Settings.TryGetPropertyValue("AIProfileFunctionInvocationMetadata", out var legacyNode))
if (context.Context.ToolNames is not { Length: > 0 } &&
profile.Settings.TryGetValue("AIProfileFunctionInvocationMetadata", out var legacyNode))
{
var legacyMetadata = legacyNode.Deserialize<FunctionInvocationMetadata>();
var legacyMetadata = legacyNode switch
{
JsonNode jsonNode => jsonNode.Deserialize<FunctionInvocationMetadata>(),
JsonElement jsonElement => jsonElement.Deserialize<FunctionInvocationMetadata>(),
_ => JsonSerializer.Deserialize<FunctionInvocationMetadata>(JsonSerializer.Serialize(legacyNode)),
};

if (legacyMetadata?.Names is { Length: > 0 })
{
context.Context.ToolNames = legacyMetadata.Names;
Expand Down
9 changes: 6 additions & 3 deletions src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,12 @@ private static void MergeSettings(AIProfile profile, JsonObject json)
return;
}

var existingSettingsSnapshot = profile.Settings.Clone();
var currentSettingsJson = JsonExtensions.FromObject(profile.Settings ?? new Dictionary<string, object>(), ExtensibleEntityExtensions.JsonSerializerOptions);
var existingSettingsSnapshot = currentSettingsJson.Clone();

AIPropertiesMergeHelper.Merge(profile.Settings, settings);
AIPropertiesMergeHelper.MergeNamedEntries(profile.Settings, existingSettingsSnapshot);
AIPropertiesMergeHelper.Merge(currentSettingsJson, settings);
AIPropertiesMergeHelper.MergeNamedEntries(currentSettingsJson, existingSettingsSnapshot);

profile.Settings = JsonSerializer.Deserialize<Dictionary<string, object>>(currentSettingsJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public async Task AIProfileHandler_MergesIncomingPropertiesAndSettings()
},
},
},
Settings = new JsonObject
Settings = new Dictionary<string, object>
{
["CustomSettings"] = new JsonObject
{
Expand Down Expand Up @@ -175,9 +175,10 @@ public async Task AIProfileHandler_MergesIncomingPropertiesAndSettings()
await handler.InitializingAsync(new InitializingContext<AIProfile>(profile, data), CancellationToken);

var properties = JsonExtensions.FromObject(profile.Properties);
var settings = JsonExtensions.FromObject(profile.Settings);
var propertyEntries = Assert.IsType<JsonArray>(properties["Entries"]);
var firstPropertyEntry = Assert.IsType<JsonObject>(propertyEntries[0]);
var customSettings = Assert.IsType<JsonObject>(profile.Settings["CustomSettings"]);
var customSettings = Assert.IsType<JsonObject>(settings["CustomSettings"]);
var settingsEntries = Assert.IsType<JsonArray>(customSettings["Entries"]);
var firstSettingsEntry = Assert.IsType<JsonObject>(settingsEntries[0]);

Expand Down
119 changes: 119 additions & 0 deletions tests/CrestApps.Core.Tests/Core/Models/AIProfileExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,125 @@ public void GetSettings_FallsBackToFrameworkDefaultsWhenNoOptionsProvided()
Assert.Equal(TestMode.SecondValue, settings.Mode);
}

[Fact]
public void Serialize_WritesTypedSettingsInsideSettingsObject()
{
var profile = new AIProfile
{
Name = "profile-1",
};

profile.WithSettings(new TestSettings
{
Mode = TestMode.SecondValue,
});

var json = JsonSerializer.Serialize(profile);
var node = JsonNode.Parse(json)?.AsObject();

Assert.NotNull(node);
Assert.Equal("profile-1", node[nameof(AIProfile.Name)]?.GetValue<string>());
Assert.Null(node[nameof(TestSettings)]);
Assert.Equal(
"SecondValue",
node[nameof(AIProfile.Settings)]?[nameof(TestSettings)]?[nameof(TestSettings.Mode)]?.GetValue<string>());
}

[Fact]
public void Deserialize_ReadsTypedSettingsFromNestedSettingsObject()
{
const string json = """
{
"Name": "profile-1",
"Settings": {
"TestSettings": {
"Mode": "SecondValue"
}
}
}
""";

var profile = JsonSerializer.Deserialize<AIProfile>(json);

Assert.NotNull(profile);
Assert.True(profile.TryGetSettings<TestSettings>(out var settings));
Assert.NotNull(settings);
Assert.Equal(TestMode.SecondValue, settings.Mode);
}

[Fact]
public void Deserialize_IgnoresFlattenedSettingsOutsideNestedSettingsObject()
{
const string json = """
{
"Name": "profile-1",
"TestSettings": {
"Mode": "SecondValue"
}
}
""";

var profile = JsonSerializer.Deserialize<AIProfile>(json);

Assert.NotNull(profile);
Assert.False(profile.TryGetSettings<TestSettings>(out _));
}

[Fact]
public void WithSettings_RoundTripsTypedSettingsOnlyThroughSettingsObject()
{
var profile = new AIProfile
{
Name = "profile-1",
};

profile.WithSettings(new TestSettings
{
Mode = TestMode.SecondValue,
});

var serializedJson = JsonSerializer.Serialize(profile);
var serializedNode = JsonNode.Parse(serializedJson)?.AsObject();

Assert.NotNull(serializedNode);
Assert.Null(serializedNode[nameof(TestSettings)]);
Assert.Equal("profile-1", serializedNode[nameof(AIProfile.Name)]?.GetValue<string>());

var settingsNode = serializedNode[nameof(AIProfile.Settings)]?.AsObject();

Assert.NotNull(settingsNode);
Assert.Single(settingsNode);
Assert.Equal(
"SecondValue",
settingsNode[nameof(TestSettings)]?[nameof(TestSettings.Mode)]?.GetValue<string>());
Assert.Null(settingsNode[nameof(TestSettings.Mode)]);

const string roundTripJson = """
{
"Name": "profile-1",
"TestSettings": {
"Mode": "FirstValue"
},
"Settings": {
"TestSettings": {
"Mode": "SecondValue"
}
}
}
""";

var roundTrippedProfile = JsonSerializer.Deserialize<AIProfile>(roundTripJson);

Assert.NotNull(roundTrippedProfile);
Assert.True(roundTrippedProfile.TryGetSettings<TestSettings>(out var settings));
Assert.NotNull(settings);
Assert.Equal(TestMode.SecondValue, settings.Mode);

var property = Assert.Single(roundTrippedProfile.Settings);
Assert.Equal(nameof(TestSettings), property.Key);
Assert.False(roundTrippedProfile.Settings.ContainsKey(nameof(TestSettings.Mode)));
}

private static JsonSerializerOptions CreateCamelCaseEnumOptions()
{
var options = new JsonSerializerOptions(ExtensibleEntityJsonOptions.CreateDefaultSerializerOptions());
Expand Down
Loading