Skip to content

Commit 3323dbd

Browse files
committed
Add console JSON logging extension
* Usage: `builder.UseJsonConsoleLogging(askConfirmation: true)` The confirmation will cause a console prompt before injecting the logging. This makes it easier to consume in a console interactive environment (which is the main intended usage scenario).
1 parent 7b8c1ed commit 3323dbd

File tree

9 files changed

+215
-18
lines changed

9 files changed

+215
-18
lines changed

src/AI.Tests/AI.Tests.csproj

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net9.0</TargetFramework>
4+
<TargetFramework>net8.0</TargetFramework>
55
<NoWarn>OPENAI001;$(NoWarn)</NoWarn>
66
</PropertyGroup>
77

88
<ItemGroup>
99
<PackageReference Include="coverlet.collector" Version="6.0.4" />
10-
<PackageReference Include="Microsoft.Extensions.AI" Version="9.4.3-preview.1.25230.7" />
11-
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.4" />
12-
<PackageReference Include="OpenAI" Version="2.2.0-beta.4" />
10+
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.5.0-preview.1.25265.7" />
1311
<PackageReference Include="xunit" Version="2.9.3" />
1412
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" PrivateAssets="all" />
1513
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
16-
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />
17-
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" />
18-
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.4" />
19-
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
20-
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.4.3-preview.1.25230.7" />
14+
15+
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
16+
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.5" />
17+
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.5" />
18+
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.5" />
19+
20+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
21+
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
22+
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
23+
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.5" />
2124
</ItemGroup>
2225

2326
<ItemGroup>

src/AI/OpenAI/OpenAIResponseClientExtensions.cs renamed to src/AI.Tests/OpenAIResponseClientExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient
1818

1919
class ToolsReponseClient(OpenAIResponseClient inner, ResponseTool[] tools) : OpenAIResponseClient
2020
{
21-
public override Task<ClientResult<OpenAIResponse>> CreateResponseAsync(IEnumerable<ResponseItem> inputItems, ResponseCreationOptions options = null, CancellationToken cancellationToken = default)
21+
public override Task<ClientResult<OpenAIResponse>> CreateResponseAsync(IEnumerable<ResponseItem> inputItems, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default)
2222
=> inner.CreateResponseAsync(inputItems, AddTools(options), cancellationToken);
2323

24-
public override AsyncCollectionResult<StreamingResponseUpdate> CreateResponseStreamingAsync(IEnumerable<ResponseItem> inputItems, ResponseCreationOptions options = null, CancellationToken cancellationToken = default)
24+
public override AsyncCollectionResult<StreamingResponseUpdate> CreateResponseStreamingAsync(IEnumerable<ResponseItem> inputItems, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default)
2525
=> inner.CreateResponseStreamingAsync(inputItems, AddTools(options), cancellationToken);
2626

2727
ResponseCreationOptions AddTools(ResponseCreationOptions options)

src/AI.Tests/RetrievalTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Devlooped.Extensions.AI;
1212
public class RetrievalTests(ITestOutputHelper output)
1313
{
1414
[SecretsTheory("OpenAI:Key")]
15-
[InlineData("gpt-4.1-mini", "Qué es la rebeldía en el Código Procesal Civil y Comercial Nacional?")]
15+
[InlineData("gpt-4.1-nano", "Qué es la rebeldía en el Código Procesal Civil y Comercial Nacional?")]
1616
[InlineData("gpt-4.1-nano", "What's the battery life in an iPhone 15?", true)]
1717
public async Task CanRetrieveContent(string model, string question, bool empty = false)
1818
{
@@ -31,6 +31,11 @@ public async Task CanRetrieveContent(string model, string question, bool empty =
3131
ResponseTool.CreateFileSearchTool([store.VectorStoreId]))
3232
.AsBuilder()
3333
.UseLogging(output.AsLoggerFactory())
34+
.Use((messages, options, next, cancellationToken) =>
35+
{
36+
37+
return next.Invoke(messages, options, cancellationToken);
38+
})
3439
.Build();
3540

3641
var response = await chat.GetResponseAsync(

src/AI/AI.csproj

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net9.0</TargetFramework>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<PackageId>Devlooped.Extensions.AI</PackageId>
6+
<Description>Extensions for Microsoft.Extensions.AI</Description>
7+
<PackOnBuild>true</PackOnBuild>
58
</PropertyGroup>
69

710
<ItemGroup>
8-
<PackageReference Include="Microsoft.Extensions.AI" Version="9.4.0-preview.1.25207.5" />
9-
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.4.0-preview.1.25207.5" />
10-
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.4" />
11+
<PackageReference Include="NuGetizer" Version="1.2.4" PrivateAssets="all" />
12+
<PackageReference Include="Microsoft.Extensions.AI" Version="9.5.0" />
13+
<PackageReference Include="Spectre.Console" Version="0.50.0" />
14+
<PackageReference Include="Spectre.Console.Json" Version="0.50.0" />
1115
</ItemGroup>
1216

13-
</Project>
17+
<ItemGroup>
18+
<None Update="Devlooped.Extensions.AI.props" PackFolder="build" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<Project>
2+
<PropertyGroup>
3+
<ImplicitUsings>true</ImplicitUsings>
4+
<Nullable>enable</Nullable>
5+
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<Using Include="System.Collections.Generic"/>
10+
<Using Include="System.Linq"/>
11+
<Using Include="System.Net"/>
12+
<Using Include="System.Net.Http"/>
13+
<Using Include="System.Reflection"/>
14+
<Using Include="System.Text.Json"/>
15+
<Using Include="System.Threading"/>
16+
<Using Include="System.Threading.Tasks"/>
17+
18+
<Using Include="Microsoft.Extensions.Configuration"/>
19+
<Using Include="Microsoft.Extensions.Configuration.UserSecrets"/>
20+
<Using Include="Microsoft.Extensions.DependencyInjection"/>
21+
<Using Include="Microsoft.Extensions.Hosting"/>
22+
<Using Include="Microsoft.Extensions.AI"/>
23+
24+
<Using Include="Spectre.Console"/>
25+
<Using Include="Spectre.Console.Json"/>
26+
27+
<Using Include="Polly"/>
28+
</ItemGroup>
29+
30+
<ItemGroup>
31+
<AssemblyMetadata Include="MSBuildProjectName" Value="$(MSBuildProjectName)" />
32+
</ItemGroup>
33+
34+
</Project>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.ComponentModel;
2+
using System.Runtime.CompilerServices;
3+
using Devlooped.Extensions.AI;
4+
using Spectre.Console;
5+
using Spectre.Console.Json;
6+
7+
namespace Microsoft.Extensions.AI;
8+
9+
/// <summary>
10+
/// Adds console logging capabilities to the chat client.
11+
/// </summary>
12+
[EditorBrowsable(EditorBrowsableState.Never)]
13+
public static class JsonConsoleLoggingExtensions
14+
{
15+
/// <summary>
16+
/// Renders chat messages and responses to the console using Spectre.Console rich JSON formatting.
17+
/// </summary>
18+
/// <param name="builder">The builder in use.</param>
19+
/// <param name="askConfirmation">If true, prompts the user for confirmation before enabling console logging.</param>
20+
public static ChatClientBuilder UseJsonConsoleLogging(this ChatClientBuilder builder, bool askConfirmation = false)
21+
{
22+
if (askConfirmation && !AnsiConsole.Confirm("Do you want to enable console logging for chat messages?"))
23+
return builder;
24+
25+
return builder.Use(inner => new ConsoleLoggingChatClient(inner));
26+
}
27+
28+
class ConsoleLoggingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
29+
{
30+
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
31+
{
32+
AnsiConsole.Write(new Panel(new JsonText(new
33+
{
34+
messages = messages.Where(x => x.Role != ChatRole.System).ToArray(),
35+
options
36+
}.ToJsonString())));
37+
38+
var response = await InnerClient.GetResponseAsync(messages, options, cancellationToken);
39+
40+
AnsiConsole.Write(new Panel(new JsonText(response.ToJsonString())));
41+
return response;
42+
}
43+
44+
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
45+
{
46+
AnsiConsole.Write(new Panel(new JsonText(new
47+
{
48+
messages = messages.Where(x => x.Role != ChatRole.System).ToArray(),
49+
options
50+
}.ToJsonString())));
51+
52+
List<ChatResponseUpdate> updates = [];
53+
54+
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
55+
{
56+
updates.Add(update);
57+
yield return update;
58+
}
59+
60+
AnsiConsole.Write(new Panel(new JsonText(updates.ToJsonString())));
61+
}
62+
}
63+
}

src/AI/JsonExtensions.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
3+
4+
namespace Devlooped.Extensions.AI;
5+
6+
static class JsonExtensions
7+
{
8+
static readonly JsonSerializerOptions options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
9+
10+
/// <summary>
11+
/// Recursively truncates long strings in an object before serialization and optionally excludes additional properties.
12+
/// </summary>
13+
public static string ToJsonString(this object? value, int maxStringLength = 100, bool includeAdditionalProperties = true)
14+
{
15+
if (value is null)
16+
return "{}";
17+
18+
var node = JsonSerializer.SerializeToNode(value, value.GetType(), options);
19+
return FilterNode(node, maxStringLength, includeAdditionalProperties)?.ToJsonString() ?? "{}";
20+
}
21+
22+
static JsonNode? FilterNode(JsonNode? node, int maxStringLength, bool includeAdditionalProperties)
23+
{
24+
if (node is JsonObject obj)
25+
{
26+
var filtered = new JsonObject();
27+
foreach (var prop in obj)
28+
{
29+
if (!includeAdditionalProperties && prop.Key == "AdditionalProperties")
30+
continue;
31+
if (FilterNode(prop.Value, maxStringLength, includeAdditionalProperties) is JsonNode value)
32+
filtered[prop.Key] = value.DeepClone();
33+
}
34+
return filtered;
35+
}
36+
if (node is JsonArray arr)
37+
{
38+
var filtered = new JsonArray();
39+
foreach (var item in arr)
40+
{
41+
if (FilterNode(item, maxStringLength, includeAdditionalProperties) is JsonNode value)
42+
filtered.Add(value.DeepClone());
43+
}
44+
45+
return filtered;
46+
}
47+
if (node is JsonValue val && val.TryGetValue(out string? str) && str is not null && str.Length > maxStringLength)
48+
{
49+
return str[..maxStringLength] + "...";
50+
}
51+
return node;
52+
}
53+
}

src/Directory.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<UserSecretsId>6eb457f9-16bc-49c5-81f2-33399b254e04</UserSecretsId>
88

99
<RestoreSources>https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json</RestoreSources>
10+
<PackageProjectUrl>https://github.com/devlooped/Extensions.AI</PackageProjectUrl>
1011
</PropertyGroup>
1112

1213
</Project>

src/Directory.targets

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1-
<Project>
1+
<Project InitialTargets="SetLocalVersion">
2+
3+
<Target Name="SetLocalVersion" Condition="!$(CI)">
4+
<GetVersion>
5+
<Output TaskParameter="Version" PropertyName="Version" />
6+
</GetVersion>
7+
</Target>
8+
9+
<UsingTask TaskName="GetVersion" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
10+
<ParameterGroup>
11+
<Version Output="true" />
12+
</ParameterGroup>
13+
<Task>
14+
<Using Namespace="System" />
15+
<Using Namespace="Microsoft.Build.Framework"/>
16+
<Code Type="Fragment" Language="cs">
17+
<![CDATA[
18+
var version = this.BuildEngine4.GetRegisteredTaskObject("Version", RegisteredTaskObjectLifetime.Build);
19+
if (version == null)
20+
{
21+
var epoc = DateTime.Parse("2024-03-15");
22+
var days = Math.Truncate(DateTime.UtcNow.Subtract(epoc).TotalDays);
23+
var time = Math.Floor(DateTime.UtcNow.TimeOfDay.TotalMinutes);
24+
version = "42." + days + "." + time;
25+
this.BuildEngine4.RegisterTaskObject("Version", version, RegisteredTaskObjectLifetime.Build, false);
26+
}
27+
Version = (string)version;
28+
]]>
29+
</Code>
30+
</Task>
31+
</UsingTask>
232

333
</Project>

0 commit comments

Comments
 (0)