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
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ var requests = new List<JsonNode>();
var responses = new List<JsonNode>();
var openai = new OpenAIClient(
Env.Get("OPENAI_API_KEY")!,
ClientOptions.Observe(requests.Add, responses.Add));
OpenAIClientOptions.Observable(requests.Add, responses.Add));
```


Expand Down
25 changes: 17 additions & 8 deletions src/AI.Tests/Extensions/PipelineTestOutput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,28 @@

namespace Devlooped.Extensions.AI;

public static class PipelineTestOutput
public static class PipelineOutput
{
static readonly JsonSerializerOptions options = new(JsonSerializerDefaults.General)
extension<TOptions>(TOptions) where TOptions : ClientPipelineOptions, new()
{
public static TOptions WriteTo(ITestOutputHelper output)
=> new TOptions().WriteTo(output);
}
}

public static class PipelineOutputExtensions
{
static readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.General)
{
WriteIndented = true,
};

public static TOptions WriteTo<TOptions>(this TOptions pipelineOptions, ITestOutputHelper output = default)
where TOptions : ClientPipelineOptions
extension<TOptions>(TOptions options) where TOptions : ClientPipelineOptions
{
return pipelineOptions.Observe(
request => output.WriteLine(request.ToJsonString(options)),
response => output.WriteLine(response.ToJsonString(options))
);
public TOptions WriteTo(ITestOutputHelper output)
=> options.Observe(
request => output.WriteLine(request.ToJsonString(jsonOptions)),
response => output.WriteLine(response.ToJsonString(jsonOptions))
);
}
}
12 changes: 6 additions & 6 deletions src/AI.Tests/GrokTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ public async Task GrokInvokesToolAndSearch()
var requests = new List<JsonNode>();
var responses = new List<JsonNode>();

var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3",
ClientOptions.Observable(requests.Add, responses.Add)
.WriteTo(output))
var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAI.OpenAIClientOptions
.Observable(requests.Add, responses.Add)
.WriteTo(output))
.AsBuilder()
.UseFunctionInvocation()
.Build();
Expand Down Expand Up @@ -105,9 +105,9 @@ public async Task GrokInvokesHostedSearchTool()
var requests = new List<JsonNode>();
var responses = new List<JsonNode>();

var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3",
ClientOptions.Observable(requests.Add, responses.Add)
.WriteTo(output));
var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAI.OpenAIClientOptions
.Observable(requests.Add, responses.Add)
.WriteTo(output));

var options = new ChatOptions
{
Expand Down
5 changes: 3 additions & 2 deletions src/AI.Tests/OpenAITests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
using OpenAI;
using static ConfigurationExtensions;

namespace Devlooped.Extensions.AI;
Expand All @@ -15,7 +16,7 @@ public async Task OpenAISwitchesModel()
};

var chat = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "gpt-4.1-nano",
new OpenAI.OpenAIClientOptions().WriteTo(output));
OpenAIClientOptions.WriteTo(output));

var options = new ChatOptions
{
Expand All @@ -41,7 +42,7 @@ public async Task OpenAIThinks()
var requests = new List<JsonNode>();

var chat = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "o3-mini",
ClientOptions.Observable(requests.Add).WriteTo(output));
OpenAIClientOptions.Observable(requests.Add).WriteTo(output));

var options = new ChatOptions
{
Expand Down
31 changes: 0 additions & 31 deletions src/AI/ClientOptions.cs

This file was deleted.

56 changes: 36 additions & 20 deletions src/AI/ClientPipelineExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,44 @@

namespace Devlooped.Extensions.AI;

/// <summary>
/// Provides extension methods for <see cref="ClientPipelineOptions"/>.
/// </summary>
public static class ClientPipelineExtensions
{
/// <summary>
/// Adds a <see cref="PipelinePolicy"/> that observes requests and response
/// messages from the <see cref="ClientPipeline"/> and notifies the provided
/// callbacks with the JSON representation of the HTTP messages.
/// </summary>
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
/// <param name="pipelineOptions">The options instance to configure.</param>
/// <param name="onRequest">A callback to process the <see cref="JsonNode"/> that was sent.</param>
/// <param name="onResponse">A callback to process the <see cref="JsonNode"/> that was received.</param>
/// <remarks>
/// This is the lowst-level logging after all chat pipeline processing has been done.
/// If no <see cref="JsonNode"/> can be parsed from the request or response,
/// the callbacks will not be invoked.
/// </remarks>
public static TOptions Observe<TOptions>(this TOptions pipelineOptions,
Action<JsonNode>? onRequest = default, Action<JsonNode>? onResponse = default)
where TOptions : ClientPipelineOptions
extension<TOptions>(TOptions) where TOptions : ClientPipelineOptions, new()
{
pipelineOptions.AddPolicy(new ObservePipelinePolicy(onRequest, onResponse), PipelinePosition.BeforeTransport);
return pipelineOptions;
/// <summary>
/// Creates an instance of the <see cref="TOptions"/> that can be observed for requests and responses.
/// </summary>
/// <param name="onRequest">A callback to process the <see cref="JsonNode"/> that was sent.</param>
/// <param name="onResponse">A callback to process the <see cref="JsonNode"/> that was received.</param>
/// <returns>A new instance of <typeparamref name="TOptions"/>.</returns>
public static TOptions Observable(Action<JsonNode>? onRequest = default, Action<JsonNode>? onResponse = default)
=> new TOptions().Observe(onRequest, onResponse);
}

extension<TOptions>(TOptions options) where TOptions : ClientPipelineOptions
{
/// <summary>
/// Adds a <see cref="PipelinePolicy"/> that observes requests and response
/// messages from the <see cref="ClientPipeline"/> and notifies the provided
/// callbacks with the JSON representation of the HTTP messages.
/// </summary>
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
/// <param name="pipelineOptions">The options instance to configure.</param>
/// <param name="onRequest">A callback to process the <see cref="JsonNode"/> that was sent.</param>
/// <param name="onResponse">A callback to process the <see cref="JsonNode"/> that was received.</param>
/// <remarks>
/// This is the lowst-level logging after all chat pipeline processing has been done.
/// If no <see cref="JsonNode"/> can be parsed from the request or response,
/// the callbacks will not be invoked.
/// </remarks>
public TOptions Observe(Action<JsonNode>? onRequest = default, Action<JsonNode>? onResponse = default)
{
options.AddPolicy(new ObservePipelinePolicy(onRequest, onResponse), PipelinePosition.BeforeTransport);
return options;
}
}

class ObservePipelinePolicy(Action<JsonNode>? onRequest = default, Action<JsonNode>? onResponse = default) : PipelinePolicy
Expand Down Expand Up @@ -78,4 +94,4 @@ void NotifyObservers(PipelineMessage message)
}
}
}
}
}
115 changes: 35 additions & 80 deletions src/AI/Console/JsonConsoleLoggingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,97 +12,52 @@ namespace Microsoft.Extensions.AI;
[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="pipelineOptions">The options instance to configure.</param>
/// <remarks>
/// NOTE: this is the lowest-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 pipelineOptions, JsonConsoleOptions? consoleOptions = null)
where TOptions : ClientPipelineOptions
extension<TOptions>(TOptions pipelineOptions) where TOptions : ClientPipelineOptions
{
consoleOptions ??= JsonConsoleOptions.Default;

if (consoleOptions.InteractiveConfirm && ConsoleExtensions.IsConsoleInteractive && !AnsiConsole.Confirm("Do you want to enable rich JSON console logging for HTTP pipeline messages?"))
return pipelineOptions;

if (consoleOptions.InteractiveOnly && !ConsoleExtensions.IsConsoleInteractive)
return pipelineOptions;

pipelineOptions.AddPolicy(new JsonConsoleLoggingPipelinePolicy(consoleOptions), PipelinePosition.BeforeTransport);
return pipelineOptions;
}

/// <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, JsonConsoleOptions? consoleOptions = null)
{
consoleOptions ??= JsonConsoleOptions.Default;
/// <summary>
/// Observes the HTTP request and response messages from the underlying pipeline and renders them
/// 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="pipelineOptions">The options instance to configure.</param>
/// <see cref="ClientPipelineExtensions.Observe"/>
public TOptions UseJsonConsoleLogging(JsonConsoleOptions? consoleOptions = null)
{
consoleOptions ??= JsonConsoleOptions.Default;

if (consoleOptions.InteractiveConfirm && ConsoleExtensions.IsConsoleInteractive && !AnsiConsole.Confirm("Do you want to enable rich JSON console logging for HTTP pipeline messages?"))
return builder;
if (consoleOptions.InteractiveConfirm && ConsoleExtensions.IsConsoleInteractive && !AnsiConsole.Confirm("Do you want to enable rich JSON console logging for HTTP pipeline messages?"))
return pipelineOptions;

if (consoleOptions.InteractiveOnly && !ConsoleExtensions.IsConsoleInteractive)
return builder;
if (consoleOptions.InteractiveOnly && !ConsoleExtensions.IsConsoleInteractive)
return pipelineOptions;

return builder.Use(inner => new JsonConsoleLoggingChatClient(inner, consoleOptions));
return pipelineOptions.Observe(
request => AnsiConsole.Write(consoleOptions.CreatePanel(request)),
response => AnsiConsole.Write(consoleOptions.CreatePanel(response)));
}
}

class JsonConsoleLoggingPipelinePolicy(JsonConsoleOptions consoleOptions) : PipelinePolicy
extension(ChatClientBuilder builder)
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
/// <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 ChatClientBuilder UseJsonConsoleLogging(JsonConsoleOptions? consoleOptions = null)
{
message.BufferResponse = true;
ProcessNext(message, pipeline, currentIndex);

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 = reader.ReadToEnd();
AnsiConsole.Write(consoleOptions.CreatePanel(content));
}

if (message.Response != null)
{
AnsiConsole.Write(consoleOptions.CreatePanel(message.Response.Content.ToString()));
}
}
consoleOptions ??= JsonConsoleOptions.Default;

public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.BufferResponse = true;
await ProcessNextAsync(message, pipeline, currentIndex);
if (consoleOptions.InteractiveConfirm && ConsoleExtensions.IsConsoleInteractive && !AnsiConsole.Confirm("Do you want to enable rich JSON console logging for HTTP pipeline messages?"))
return builder;

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(consoleOptions.CreatePanel(content));
}
if (consoleOptions.InteractiveOnly && !ConsoleExtensions.IsConsoleInteractive)
return builder;

if (message.Response != null)
{
AnsiConsole.Write(consoleOptions.CreatePanel(message.Response.Content.ToString()));
}
return builder.Use(inner => new JsonConsoleLoggingChatClient(inner, consoleOptions));
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/AI/Console/JsonConsoleOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ internal Panel CreatePanel(string json)
return panel;
}

internal Panel CreatePanel(JsonNode node)
{
string json;

// Determine if we need to pre-process the JSON node based on the settings.
if (TruncateLength.HasValue || !IncludeAdditionalProperties)
{
json = node.ToShortJsonString(TruncateLength, IncludeAdditionalProperties);
}
else
{
// i.e. we had no pre-processing to do
json = node.ToJsonString();
}

var panel = new Panel(WrapLength.HasValue ? new WrappedJsonText(json, WrapLength.Value) : new JsonText(json))
{
Border = Border,
BorderStyle = BorderStyle,
};
return panel;
}

internal Panel CreatePanel(object value)
{
string? json = null;
Expand Down
Loading