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
10 changes: 10 additions & 0 deletions .netconfig
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,13 @@
sha = 77e83f238196d2723640abef0c7b6f43994f9747
etag = fcb9759a96966df40dcd24906fd328ddec05953b7e747a6bb8d0d1e4c3865274
weak
[file "src/AI/Extensions/System/Throw.cs"]
url = https://github.com/devlooped/catbag/blob/main/System/Throw.cs
sha = 3012d56be7554c483e5c5d277144c063969cada9
etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0
weak
[file "src/Weaving/Extensions/System/Throw.cs"]
url = https://github.com/devlooped/catbag/blob/main/System/Throw.cs
sha = 3012d56be7554c483e5c5d277144c063969cada9
etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0
weak
2 changes: 1 addition & 1 deletion src/AI.Tests/AI.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.5.0-preview.1.25265.7" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.6.0-preview.1.25310.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
Expand Down
7 changes: 5 additions & 2 deletions src/AI/AI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>Preview</LangVersion>
<PackageId>Devlooped.Extensions.AI</PackageId>
<Description>Extensions for Microsoft.Extensions.AI</Description>
<PackOnBuild>true</PackOnBuild>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="NuGetizer" Version="1.2.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.AI" Version="9.5.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="9.6.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.6.0-preview.1.25310.2" />
<PackageReference Include="OpenAI" Version="2.2.0-beta.4" />
<PackageReference Include="Spectre.Console" Version="0.50.0" />
<PackageReference Include="Spectre.Console.Json" Version="0.50.0" />
</ItemGroup>
Expand Down
10 changes: 10 additions & 0 deletions src/AI/Console/ConsoleExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Devlooped.Extensions.AI;

static class ConsoleExtensions
{
public static bool IsConsoleInteractive =>
!Console.IsInputRedirected &&
!Console.IsOutputRedirected &&
!Console.IsErrorRedirected &&
Environment.UserInteractive;
}
57 changes: 57 additions & 0 deletions src/AI/Console/JsonConsoleLoggingChatClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
using Spectre.Console;
using Spectre.Console.Json;

namespace Devlooped.Extensions.AI;

/// <summary>
/// Chat client that logs messages and responses to the console in JSON format using Spectre.Console.
/// </summary>
/// <param name="innerClient"></param>
public class JsonConsoleLoggingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
{
/// <summary>
/// Whether to include additional properties in the JSON output.
/// </summary>
public bool IncludeAdditionalProperties { get; set; } = true;

/// <summary>
/// Optional maximum length to render for string values. Replaces remaining characters with "...".
/// </summary>
public int? MaxLength { get; set; }

public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
AnsiConsole.Write(new Panel(new JsonText(new
{
messages = messages.Where(x => x.Role != ChatRole.System).ToArray(),
options
}.ToJsonString(MaxLength, IncludeAdditionalProperties))));

var response = await InnerClient.GetResponseAsync(messages, options, cancellationToken);

AnsiConsole.Write(new Panel(new JsonText(response.ToJsonString(MaxLength, IncludeAdditionalProperties))));
return response;
}

public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AnsiConsole.Write(new Panel(new JsonText(new
{
messages = messages.Where(x => x.Role != ChatRole.System).ToArray(),
options
}.ToJsonString(MaxLength, IncludeAdditionalProperties))));

List<ChatResponseUpdate> updates = [];

await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
{
updates.Add(update);
yield return update;
}

AnsiConsole.Write(new Panel(new JsonText(updates.ToJsonString(MaxLength, IncludeAdditionalProperties))));
}
}

119 changes: 119 additions & 0 deletions src/AI/Console/JsonConsoleLoggingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.ClientModel.Primitives;
using System.ComponentModel;
using Devlooped.Extensions.AI;
using Spectre.Console;
using Spectre.Console.Json;

namespace Microsoft.Extensions.AI;

/// <summary>
/// Adds console logging capabilities to the chat client and pipeline transport.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class JsonConsoleLoggingExtensions
{
/// <summary>
/// Sets a <see cref="ClientPipelineOptions.Transport"/> that renders HTTP messages to the
/// console using Spectre.Console rich JSON formatting, but only if the console is interactive.
/// </summary>
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
/// <param name="options">The options instance to configure.</param>
/// <remarks>
/// NOTE: this is the lowst-level logging after all chat pipeline processing has been done.
/// <para>
/// If the options already provide a transport, it will be wrapped with the console
/// logging transport to minimize the impact on existing configurations.
/// </para>
/// </remarks>
public static TOptions UseJsonConsoleLogging<TOptions>(this TOptions options)
where TOptions : ClientPipelineOptions
=> UseJsonConsoleLogging(options, ConsoleExtensions.IsConsoleInteractive);

/// <summary>
/// Sets a <see cref="ClientPipelineOptions.Transport"/> that renders HTTP messages to the
/// console using Spectre.Console rich JSON formatting.
/// </summary>
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
/// <param name="options">The options instance to configure.</param>
/// <param name="askConfirmation">Whether to confirm logging before enabling it.</param>
/// <remarks>
/// NOTE: this is the lowst-level logging after all chat pipeline processing has been done.
/// <para>
/// If the options already provide a transport, it will be wrapped with the console
/// logging transport to minimize the impact on existing configurations.
/// </para>
/// </remarks>
public static TOptions UseJsonConsoleLogging<TOptions>(this TOptions options, bool askConfirmation)
where TOptions : ClientPipelineOptions
{
if (askConfirmation && !AnsiConsole.Confirm("Do you want to enable rich JSON console logging for HTTP pipeline messages?"))
return options;

options.Transport = new ConsoleLoggingPipelineTransport(options.Transport ?? HttpClientPipelineTransport.Shared);

return options;
}

/// <summary>
/// Renders chat messages and responses to the console using Spectre.Console rich JSON formatting.
/// </summary>
/// <param name="builder">The builder in use.</param>
/// <remarks>
/// Confirmation will be asked if the console is interactive, otherwise, it will be
/// enabled unconditionally.
/// </remarks>
public static ChatClientBuilder UseJsonConsoleLogging(this ChatClientBuilder builder)
=> UseJsonConsoleLogging(builder, ConsoleExtensions.IsConsoleInteractive);

/// <summary>
/// Renders chat messages and responses to the console using Spectre.Console rich JSON formatting.
/// </summary>
/// <param name="builder">The builder in use.</param>
/// <param name="askConfirmation">If true, prompts the user for confirmation before enabling console logging.</param>
/// <param name="maxLength">Optional maximum length to render for string values. Replaces remaining characters with "...".</param>
public static ChatClientBuilder UseJsonConsoleLogging(this ChatClientBuilder builder, bool askConfirmation = false, Action<JsonConsoleLoggingChatClient>? configure = null)
{
if (askConfirmation && !AnsiConsole.Confirm("Do you want to enable console logging for chat messages?"))
return builder;

return builder.Use(inner =>
{
var client = new JsonConsoleLoggingChatClient(inner);
configure?.Invoke(client);
return client;
});
}

class ConsoleLoggingPipelineTransport(PipelineTransport inner) : PipelineTransport
{
public static PipelineTransport Default { get; } = new ConsoleLoggingPipelineTransport();

public ConsoleLoggingPipelineTransport() : this(HttpClientPipelineTransport.Shared) { }

protected override PipelineMessage CreateMessageCore() => inner.CreateMessage();
protected override void ProcessCore(PipelineMessage message) => inner.Process(message);

protected override async ValueTask ProcessCoreAsync(PipelineMessage message)
{
message.BufferResponse = true;
await inner.ProcessAsync(message);

if (message.Request.Content is not null)
{
using var memory = new MemoryStream();
message.Request.Content.WriteTo(memory);
memory.Position = 0;
using var reader = new StreamReader(memory);
var content = await reader.ReadToEndAsync();

AnsiConsole.Write(new Panel(new JsonText(content)));
}

if (message.Response != null)
{
AnsiConsole.Write(new Panel(new JsonText(message.Response.Content.ToString())));
}
}
}

}
Loading
Loading