From 2c75f13337488b96eff0c480c353f7221bc851d6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 12 Jun 2025 13:22:07 +0100 Subject: [PATCH] .Net: Add ChatClientAgent Samples - OpenAI Model Client (#72) * Add Streaming API * Removing InstructionsRole * Updating thread notification strategy * Fix net472 failing * Small fixes * Adding Samples for OpenAI * WIP samples * default runsettings for unit tests * Adding first samples with OpenAIModelChatClientAgents * Removing OpenAI dependency on the sample utility * Release -> Debug update for GettingStarted project * Fix GettingStarted.csproj failing to build in Release * Update dotnet/src/Shared/Samples/BaseSample.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR feedback * Fix Step 1 samples * Simplify code * Address PR feedback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 15 +- dotnet/agent-framework-dotnet.slnx | 13 ++ dotnet/eng/MSBuild/Shared.props | 3 + dotnet/samples/.editorconfig | 7 + dotnet/samples/Directory.Build.props | 27 +++ dotnet/samples/GettingStarted/AgentSample.cs | 15 ++ .../ChatClientAgent/Step01_Running.cs | 125 ++++++++++ .../ChatClientAgent/Step02_UsingTools.cs | 132 +++++++++++ .../GettingStarted/GettingStarted.csproj | 41 ++++ .../ChatClientAgentExtensions.cs | 50 +++- dotnet/src/Shared/Samples/BaseSample.cs | 215 ++++++++++++++++++ dotnet/src/Shared/Samples/README.md | 11 + .../src/Shared/Samples/TestConfiguration.cs | 81 +++++++ .../Samples/TextOutputHelperExtensions.cs | 47 ++++ dotnet/src/Shared/Samples/XunitLogger.cs | 42 ++++ dotnet/src/Shared/Throw/Throw.cs | 2 + dotnet/tests/.editorconfig | 2 +- 17 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/.editorconfig create mode 100644 dotnet/samples/Directory.Build.props create mode 100644 dotnet/samples/GettingStarted/AgentSample.cs create mode 100644 dotnet/samples/GettingStarted/ChatClientAgent/Step01_Running.cs create mode 100644 dotnet/samples/GettingStarted/ChatClientAgent/Step02_UsingTools.cs create mode 100644 dotnet/samples/GettingStarted/GettingStarted.csproj create mode 100644 dotnet/src/Shared/Samples/BaseSample.cs create mode 100644 dotnet/src/Shared/Samples/README.md create mode 100644 dotnet/src/Shared/Samples/TestConfiguration.cs create mode 100644 dotnet/src/Shared/Samples/TextOutputHelperExtensions.cs create mode 100644 dotnet/src/Shared/Samples/XunitLogger.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index a7313c7b0d..a295476d40 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -6,13 +6,20 @@ + + + + + + - - + + + - + @@ -61,4 +68,4 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 8a13e5e860..c8775abc1e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -4,12 +4,18 @@ + + + + + + @@ -20,15 +26,22 @@ + + + + + + + diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index fdd9e5a802..7d3e5ca30b 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -2,4 +2,7 @@ + + + diff --git a/dotnet/samples/.editorconfig b/dotnet/samples/.editorconfig new file mode 100644 index 0000000000..348c57a8b1 --- /dev/null +++ b/dotnet/samples/.editorconfig @@ -0,0 +1,7 @@ +# Suppressing errors for Sample projects under dotnet/samples folder +[*.cs] +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.CA1716.severity = none # Add summary to documentation comment. \ No newline at end of file diff --git a/dotnet/samples/Directory.Build.props b/dotnet/samples/Directory.Build.props new file mode 100644 index 0000000000..e5c47346d2 --- /dev/null +++ b/dotnet/samples/Directory.Build.props @@ -0,0 +1,27 @@ + + + + + + false + true + false + net472;net9.0 + net9.0 + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentSample.cs b/dotnet/samples/GettingStarted/AgentSample.cs new file mode 100644 index 0000000000..3ba34ac3fa --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSample.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.Shared.Samples; +using OpenAI; + +namespace GettingStarted; + +public class AgentSample(ITestOutputHelper output) : BaseSample(output) +{ + protected IChatClient GetOpenAIChatClient() + => new OpenAIClient(TestConfiguration.OpenAI.ApiKey) + .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + .AsIChatClient(); +} diff --git a/dotnet/samples/GettingStarted/ChatClientAgent/Step01_Running.cs b/dotnet/samples/GettingStarted/ChatClientAgent/Step01_Running.cs new file mode 100644 index 0000000000..29df4658cf --- /dev/null +++ b/dotnet/samples/GettingStarted/ChatClientAgent/Step01_Running.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents; + +namespace ChatCompletionAgent; + +/// +/// Provides test methods to demonstrate the usage of chat agents with different interaction models. +/// +/// This class contains examples of using to showcase scenarios with and without conversation history. +/// Each test method demonstrates how to configure and interact with the agents, including handling user input and displaying responses. +/// +public sealed class Step01_Running(ITestOutputHelper output) : AgentSample(output) +{ + private const string ParrotName = "Parrot"; + private const string ParrotInstructions = "Repeat the user message in the voice of a pirate and then end with a parrot sound."; + + private const string JokerName = "Joker"; + private const string JokerInstructions = "You are good at telling jokes."; + + /// + /// Demonstrate the usage of where each invocation is + /// a unique interaction with no conversation history between them. + /// + [Fact] + public async Task RunWithoutThread() + { + // Get the chat client to use for the agent. + using var chatClient = base.GetOpenAIChatClient(); + + // Define the agent + ChatClientAgent agent = + new(chatClient, new() + { + Name = ParrotName, + Instructions = ParrotInstructions, + }); + + // Respond to user input + await InvokeAgentAsync("Fortune favors the bold."); + await InvokeAgentAsync("I came, I saw, I conquered."); + await InvokeAgentAsync("Practice makes perfect."); + + // Local function to invoke agent and display the conversation messages. + async Task InvokeAgentAsync(string input) + { + this.WriteUserMessage(input); + + var response = await agent.RunAsync(input); + this.WriteResponseOutput(response); + } + } + + /// + /// Demonstrate the usage of where a conversation history is maintained. + /// + [Fact] + public async Task RunWithConversationThread() + { + // Get the chat client to use for the agent. + using var chatClient = base.GetOpenAIChatClient(); + + // Define the agent + ChatClientAgent agent = + new(chatClient, new() + { + Name = JokerName, + Instructions = JokerInstructions, + }); + + // Start a new thread for the agent conversation. + AgentThread thread = agent.GetNewThread(); + + // Respond to user input + await InvokeAgentAsync("Tell me a joke about a pirate."); + await InvokeAgentAsync("Now add some emojis to the joke."); + + // Local function to invoke agent and display the conversation messages for the thread. + async Task InvokeAgentAsync(string input) + { + this.WriteUserMessage(input); + + var response = await agent.RunAsync(input, thread); + + this.WriteResponseOutput(response); + } + } + + /// + /// Demonstrate the usage of in streaming mode, + /// where a conversation is maintained by the . + /// + [Fact] + public async Task StreamingRunWithConversationThread() + { + // Get the chat client to use for the agent. + using var chatClient = base.GetOpenAIChatClient(); + + // Define the agent + ChatClientAgent agent = + new(chatClient, new() + { + Name = ParrotName, + Instructions = ParrotInstructions, + }); + + // Start a new thread for the agent conversation. + AgentThread thread = agent.GetNewThread(); + + // Respond to user input + await InvokeAgentAsync("Tell me a joke about a pirate."); + await InvokeAgentAsync("Now add some emojis to the joke."); + + // Local function to invoke agent and display the conversation messages. + async Task InvokeAgentAsync(string input) + { + this.WriteUserMessage(input); + + await foreach (var update in agent.RunStreamingAsync(input, thread)) + { + this.WriteAgentOutput(update); + } + } + } +} diff --git a/dotnet/samples/GettingStarted/ChatClientAgent/Step02_UsingTools.cs b/dotnet/samples/GettingStarted/ChatClientAgent/Step02_UsingTools.cs new file mode 100644 index 0000000000..56dc8e3d55 --- /dev/null +++ b/dotnet/samples/GettingStarted/ChatClientAgent/Step02_UsingTools.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.Agents; +using Microsoft.Extensions.AI; + +namespace ChatCompletionAgent; + +public sealed class Step02_UsingTools(ITestOutputHelper output) : AgentSample(output) +{ + [Fact] + public async Task RunningWithTools() + { + // Get the chat client to use for the agent. + using var chatClient = base.GetOpenAIChatClient(); + + // Define the agent + ChatClientAgent agent = + new(chatClient, new() + { + Name = "Host", + Instructions = "Answer questions about the menu.", + }); + + var menuTools = new MenuTools(); + var chatOptions = new ChatOptions + { + Tools = [ + AIFunctionFactory.Create(menuTools.GetMenu), + AIFunctionFactory.Create(menuTools.GetSpecials), + AIFunctionFactory.Create(menuTools.GetItemPrice), + ], + }; + + // Create the chat history thread to capture the agent interaction. + var thread = agent.GetNewThread(); + + // Respond to user input, invoking functions where appropriate. + await InvokeAgentAsync("Hello"); + await InvokeAgentAsync("What is the special soup and its price?"); + await InvokeAgentAsync("What is the special drink and its price?"); + await InvokeAgentAsync("Thank you"); + + async Task InvokeAgentAsync(string input) + { + this.WriteUserMessage(input); + var response = await agent.RunAsync(input, thread, chatOptions: chatOptions); + this.WriteResponseOutput(response); + } + } + + [Fact] + public async Task StreamingRunWithTools() + { + // Get the chat client to use for the agent. + using var chatClient = base.GetOpenAIChatClient(); + + // Define the agent + ChatClientAgent agent = + new(chatClient, new() + { + Name = "Host", + Instructions = "Answer questions about the menu.", + }); + + var menuTools = new MenuTools(); + var chatOptions = new ChatOptions + { + Tools = [ + AIFunctionFactory.Create(menuTools.GetMenu), + AIFunctionFactory.Create(menuTools.GetSpecials), + AIFunctionFactory.Create(menuTools.GetItemPrice), + ], + }; + + // Create the chat history thread to capture the agent interaction. + var thread = agent.GetNewThread(); + + // Respond to user input, invoking functions where appropriate. + await InvokeAgentAsync("Hello"); + await InvokeAgentAsync("What is the special soup and its price?"); + await InvokeAgentAsync("What is the special drink and its price?"); + await InvokeAgentAsync("Thank you"); + + async Task InvokeAgentAsync(string input) + { + this.WriteUserMessage(input); + await foreach (var update in agent.RunStreamingAsync(input, thread, chatOptions: chatOptions)) + { + this.WriteAgentOutput(update); + } + } + } + + private sealed class MenuTools + { + [Description("Get the full menu items.")] + public MenuItem[] GetMenu() + { + return s_menuItems; + } + + [Description("Get the specials from the menu.")] + public IEnumerable GetSpecials() + { + return s_menuItems.Where(i => i.IsSpecial); + } + + [Description("Get the price of a menu item.")] + public float? GetItemPrice([Description("The name of the menu item.")] string menuItem) + { + return s_menuItems.FirstOrDefault(i => i.Name.Equals(menuItem, StringComparison.OrdinalIgnoreCase))?.Price; + } + + private static readonly MenuItem[] s_menuItems = [ + new() { Category = "Soup", Name = "Clam Chowder", Price = 4.95f, IsSpecial = true }, + new() { Category = "Soup", Name = "Tomato Soup", Price = 4.95f, IsSpecial = false }, + new() { Category = "Salad", Name = "Cobb Salad", Price = 9.99f }, + new() { Category = "Salad", Name = "House Salad", Price = 4.95f }, + new() { Category = "Drink", Name = "Chai Tea", Price = 2.95f, IsSpecial = true }, + new() { Category = "Drink", Name = "Soda", Price = 1.95f }, + ]; + + public sealed class MenuItem + { + public string Category { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public float Price { get; set; } + public bool IsSpecial { get; set; } + } + } +} diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj new file mode 100644 index 0000000000..8a07b3eec0 --- /dev/null +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -0,0 +1,41 @@ + + + + $(ProjectsTargetFrameworks) + + + + $(ProjectsDebugTargetFrameworks) + + + + GettingStarted + Library + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + $(NoWarn);CA1707;CA1716;IDE0009;IDE1006; + enable + true + + + + + + + + + + + + + + + + + true + + + + + + + diff --git a/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentExtensions.cs b/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentExtensions.cs index 32dfc3ff7e..f0d9b66dcb 100644 --- a/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents/ChatCompletion/ChatClientAgentExtensions.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents; public static class ChatClientAgentExtensions { /// - /// Run the agent with the provided message and arguments. + /// Run the agent with the provided messages and an optional thread. /// /// Target agent to run. /// The messages to pass to the agent. @@ -37,6 +37,30 @@ public static class ChatClientAgentExtensions return agent.RunAsync(messages, thread, new ChatClientAgentRunOptions(agentRunOptions, chatOptions), cancellationToken); } + /// + /// Run the agent with the provided prompt. + /// + /// Target agent to run. + /// The prompt to pass to the agent. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// Optional parameters for agent invocation. + /// Optional chat options. + /// The to monitor for cancellation requests. The default is . + /// A containing the list of items. + public static Task RunAsync( + this ChatClientAgent agent, + string prompt, + AgentThread? thread = null, + AgentRunOptions? agentRunOptions = null, + ChatOptions? chatOptions = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(agent); + Throw.IfNullOrWhitespace(prompt); + + return agent.RunAsync([new ChatMessage(ChatRole.User, prompt)], thread, agentRunOptions, chatOptions, cancellationToken); + } + /// /// Run the agent with the provided message and arguments. /// @@ -59,4 +83,28 @@ public static class ChatClientAgentExtensions return agent.RunStreamingAsync(messages, thread, new ChatClientAgentRunOptions(agentRunOptions, chatOptions), cancellationToken); } + + /// + /// Run the agent with the provided prompt in streaming mode. + /// + /// Target agent to run. + /// The prompt to pass to the agent. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// Optional parameters for agent invocation. + /// Optional chat options. + /// The to monitor for cancellation requests. The default is . + /// An async enumerable of items for streaming the response. + public static IAsyncEnumerable RunStreamingAsync( + this ChatClientAgent agent, + string prompt, + AgentThread? thread = null, + AgentRunOptions? agentRunOptions = null, + ChatOptions? chatOptions = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(agent); + Throw.IfNullOrWhitespace(prompt); + + return agent.RunStreamingAsync([new ChatMessage(ChatRole.User, prompt)], thread, agentRunOptions, chatOptions, cancellationToken); + } } diff --git a/dotnet/src/Shared/Samples/BaseSample.cs b/dotnet/src/Shared/Samples/BaseSample.cs new file mode 100644 index 0000000000..ed3986137a --- /dev/null +++ b/dotnet/src/Shared/Samples/BaseSample.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Samples; + +namespace Microsoft.Shared.SampleUtilities; + +/// +/// Provides a base class for test implementations that integrate with xUnit's and +/// logging infrastructure. This class also supports redirecting output to the test output +/// for improved debugging and test output visibility. +/// +/// +/// This class is designed to simplify the creation of test cases by providing access to logging and +/// configuration utilities, as well as enabling Console-friendly behavior for test samples. Derived classes can use +/// the property for writing test output and the property for creating +/// loggers. +/// +public abstract class BaseSample : TextWriter +{ + /// + /// Gets the output helper used for logging test results and diagnostic messages. + /// + protected ITestOutputHelper Output { get; } + + /// + /// Gets the instance used to create loggers for logging operations. + /// + protected ILoggerFactory LoggerFactory { get; } + + /// + /// This property makes the samples Console friendly. Allowing them to be copied and pasted into a Console app, with minimal changes. + /// + public BaseSample Console => this; + + /// + public override Encoding Encoding => System.Text.Encoding.UTF8; + + /// + /// Initializes a new instance of the class, setting up logging, configuration, and + /// optionally redirecting output to the test output. + /// + /// This constructor initializes logging using an and sets up + /// configuration from multiple sources, including a JSON file, environment variables, and user secrets. + /// If is , calls to + /// will be redirected to the test output provided by . + /// + /// The instance used to write test output. + /// + /// A value indicating whether output should be redirected to the test output. to redirect; otherwise, . + /// + protected BaseSample(ITestOutputHelper output, bool redirectSystemConsoleOutput = true) + { + this.Output = output; + this.LoggerFactory = new XunitLogger(output); + + IConfigurationRoot configRoot = new ConfigurationBuilder() + .AddJsonFile("appsettings.Development.json", true) + .AddEnvironmentVariables() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .Build(); + + TestConfiguration.Initialize(configRoot); + + // Redirect System.Console output to the test output if requested + if (redirectSystemConsoleOutput) + { + System.Console.SetOut(this); + } + } + + /// + /// Writes a user message to the console. + /// + /// The text of the message to be sent. Cannot be null or empty. + protected void WriteUserMessage(string message) + { + this.WriteResponseOutput(new ChatResponse(new ChatMessage(ChatRole.User, message)), printUsage: false); + } + + /// + /// Processes and writes the latest agent chat response to the console, including metadata and content details. + /// + /// This method formats and outputs the most recent message from the provided object. It includes the message role, author name (if available), text content, and + /// additional content such as images, function calls, and function results. Usage statistics, including token + /// counts, are also displayed. + /// The object containing the chat messages and usage data. + /// The flag to indicate whether to print usage information. Defaults to . + protected void WriteResponseOutput(ChatResponse chatResponse, bool? printUsage = true) + { + if (chatResponse.Messages.Count == 0) + { + // If there are no messages, we can skip writing the message. + return; + } + + var message = chatResponse.Messages.Last(); + string authorExpression = message.Role == ChatRole.User ? string.Empty : FormatAuthor(); + string contentExpression = string.IsNullOrWhiteSpace(chatResponse.Text) ? string.Empty : chatResponse.Text; + bool isCode = false; //message.AdditionalProperties?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; + string codeMarker = isCode ? "\n [CODE]\n" : " "; + Console.WriteLine($"\n# {message.Role}{authorExpression}:{codeMarker}{contentExpression}"); + + // Provide visibility for inner content (that isn't TextContent). + foreach (AIContent item in message.Contents) + { + if (item is DataContent image && image.HasTopLevelMediaType("image")) + { + Console.WriteLine($" [{item.GetType().Name}] {image.Uri?.ToString() ?? image.Uri ?? $"{image.Data.Length} bytes"}"); + } + else if (item is FunctionCallContent functionCall) + { + Console.WriteLine($" [{item.GetType().Name}] {functionCall.CallId}"); + } + else if (item is FunctionResultContent functionResult) + { + Console.WriteLine($" [{item.GetType().Name}] {functionResult.CallId} - {AsJson(functionResult.Result) ?? "*"}"); + } + } + + WriteUsage(chatResponse.Usage); + + string FormatAuthor() => message.AuthorName is not null ? $" - {message.AuthorName ?? " * "}" : string.Empty; + + void WriteUsage(UsageDetails? usageDetails) + { + if (!(printUsage ?? true) || usageDetails is null) { return; } + + Console.WriteLine($" [Usage] Tokens: {usageDetails.TotalTokenCount}, Input: {usageDetails.InputTokenCount}, Output: {usageDetails.OutputTokenCount}"); + } + } + + /// + /// Writes the streaming agent response updates to the console. + /// + /// This method formats and outputs the most recent message from the provided object. It includes the message role, author name (if available), text content, and + /// additional content such as images, function calls, and function results. Usage statistics, including token + /// counts, are also displayed. + /// The object containing the chat messages and usage data. + protected void WriteAgentOutput(ChatResponseUpdate update) + { + if (update.Contents.Count == 0) + { + // If there are no contents, we can skip writing the message. + return; + } + + string authorExpression = update.Role == ChatRole.User ? string.Empty : FormatAuthor(); + string contentExpression = string.IsNullOrWhiteSpace(update.Text) ? string.Empty : update.Text; + bool isCode = false; //message.AdditionalProperties?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; + string codeMarker = isCode ? "\n [CODE]\n" : " "; + Console.WriteLine($"\n# {update.Role}{authorExpression}:{codeMarker}{contentExpression}"); + + // Provide visibility for inner content (that isn't TextContent). + foreach (AIContent item in update.Contents) + { + if (item is DataContent image && image.HasTopLevelMediaType("image")) + { + Console.WriteLine($" [{item.GetType().Name}] {image.Uri?.ToString() ?? image.Uri ?? $"{image.Data.Length} bytes"}"); + } + else if (item is FunctionCallContent functionCall) + { + Console.WriteLine($" [{item.GetType().Name}] {functionCall.CallId}"); + } + else if (item is FunctionResultContent functionResult) + { + Console.WriteLine($" [{item.GetType().Name}] {functionResult.CallId} - {AsJson(functionResult.Result) ?? "*"}"); + } + else if (item is UsageContent usage) + { + Console.WriteLine(" [Usage] Tokens: {0}, Input: {1}, Output: {2}", + usage?.Details?.TotalTokenCount ?? 0, + usage?.Details?.InputTokenCount ?? 0, + usage?.Details?.OutputTokenCount ?? 0); + } + } + + string FormatAuthor() => update.AuthorName is not null ? $" - {update.AuthorName ?? " * "}" : string.Empty; + } + + private static readonly JsonSerializerOptions s_jsonOptionsCache = new() { WriteIndented = true }; + + private static string? AsJson(object? obj) + { + if (obj is null) { return null; } + return JsonSerializer.Serialize(obj, s_jsonOptionsCache); + } + + /// + public override void WriteLine(object? value = null) + => this.Output.WriteLine(value ?? string.Empty); + + /// + public override void WriteLine(string? format, params object?[] arg) + => this.Output.WriteLine(format ?? string.Empty, arg); + + /// + public override void WriteLine(string? value) + => this.Output.WriteLine(value ?? string.Empty); + + /// + public override void Write(object? value = null) + => this.Output.WriteLine(value ?? string.Empty); + + /// + public override void Write(char[]? buffer) + => this.Output.WriteLine(new string(buffer)); +} diff --git a/dotnet/src/Shared/Samples/README.md b/dotnet/src/Shared/Samples/README.md new file mode 100644 index 0000000000..48200dc628 --- /dev/null +++ b/dotnet/src/Shared/Samples/README.md @@ -0,0 +1,11 @@ +# Throw + +Efficient sample project utilities. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/dotnet/src/Shared/Samples/TestConfiguration.cs b/dotnet/src/Shared/Samples/TestConfiguration.cs new file mode 100644 index 0000000000..945fece430 --- /dev/null +++ b/dotnet/src/Shared/Samples/TestConfiguration.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Shared.Samples; + +/// +/// Provides a centralized configuration management system for accessing application settings. +/// +public sealed class TestConfiguration +{ + private readonly IConfigurationRoot _configRoot; + private static TestConfiguration? s_instance; + + private TestConfiguration(IConfigurationRoot configRoot) + { + this._configRoot = configRoot; + } + + /// + /// Initializes the configuration system with the specified configuration root. + /// + /// The root of the configuration hierarchy used to initialize the system. Must not be . + public static void Initialize(IConfigurationRoot configRoot) + { + s_instance = new TestConfiguration(configRoot); + } + + /// + /// Provides access to the configuration root for the application. + /// + public static IConfigurationRoot? ConfigurationRoot => s_instance?._configRoot; + + /// + /// Gets the configuration settings for the OpenAI integration. + /// + public static OpenAIConfig OpenAI => LoadSection(); + + /// + /// Retrieves a configuration section based on the specified key. + /// + /// The key identifying the configuration section to retrieve. Cannot be null or empty. + /// The corresponding to the specified key. + /// Thrown if the configuration root is not initialized or the specified key does not correspond to a valid section. + public static IConfigurationSection GetSection(string caller) + { + return s_instance?._configRoot.GetSection(caller) ?? + throw new InvalidOperationException(caller); + } + + private static T LoadSection([CallerMemberName] string? caller = null) + { + if (s_instance is null) + { + throw new InvalidOperationException( + "TestConfiguration must be initialized with a call to Initialize(IConfigurationRoot) before accessing configuration values."); + } + + if (string.IsNullOrEmpty(caller)) + { + throw new ArgumentNullException(nameof(caller)); + } + + return s_instance._configRoot.GetSection(caller).Get() ?? + throw new InvalidOperationException(caller); + } + + /// Represents the configuration settings required to interact with the OpenAI service. + public class OpenAIConfig + { + /// Gets or sets the identifier for the chat completion model used in the application. + public string? ChatModelId { get; set; } + + /// Gets or sets the identifier for the embedding model used in the application. + public string? EmbeddingModelId { get; set; } + + /// Gets or sets the API key used for authentication with the OpenAI service. + public string? ApiKey { get; set; } + } +} diff --git a/dotnet/src/Shared/Samples/TextOutputHelperExtensions.cs b/dotnet/src/Shared/Samples/TextOutputHelperExtensions.cs new file mode 100644 index 0000000000..6c8cbb7a5e --- /dev/null +++ b/dotnet/src/Shared/Samples/TextOutputHelperExtensions.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Shared.SampleUtilities; + +/// +/// Extensions for to make it more Console friendly. +/// +public static class TextOutputHelperExtensions +{ + /// + /// Current interface ITestOutputHelper does not have a WriteLine method that takes an object. This extension method adds it to make it analogous to Console.WriteLine when used in Console apps. + /// + /// Target + /// Target object to write + public static void WriteLine(this ITestOutputHelper testOutputHelper, object target) + { + testOutputHelper.WriteLine(target.ToString()); + } + + /// + /// Current interface ITestOutputHelper does not have a WriteLine method that takes no parameters. This extension method adds it to make it analogous to Console.WriteLine when used in Console apps. + /// + /// Target + public static void WriteLine(this ITestOutputHelper testOutputHelper) + { + testOutputHelper.WriteLine(string.Empty); + } + + /// + /// Current interface ITestOutputHelper does not have a Write method that takes no parameters. This extension method adds it to make it analogous to Console.Write when used in Console apps. + /// + /// Target + public static void Write(this ITestOutputHelper testOutputHelper) + { + testOutputHelper.WriteLine(string.Empty); + } + + /// + /// Current interface ITestOutputHelper does not have a Write method. This extension method adds it to make it analogous to Console.Write when used in Console apps. + /// + /// Target + /// Target object to write + public static void Write(this ITestOutputHelper testOutputHelper, object target) + { + testOutputHelper.WriteLine(target.ToString()); + } +} diff --git a/dotnet/src/Shared/Samples/XunitLogger.cs b/dotnet/src/Shared/Samples/XunitLogger.cs new file mode 100644 index 0000000000..9be281b3a3 --- /dev/null +++ b/dotnet/src/Shared/Samples/XunitLogger.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Shared.SampleUtilities; + +/// +/// A logger that writes to the Xunit test output +/// +internal sealed class XunitLogger(ITestOutputHelper output) : ILoggerFactory, ILogger, IDisposable +{ + private object? _scopeState; + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var localState = state?.ToString(); + var line = this._scopeState is not null ? $"{this._scopeState} {localState}" : localState; + output.WriteLine(line); + } + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public IDisposable BeginScope(TState state) where TState : notnull + { + this._scopeState = state; + return this; + } + + /// + public void Dispose() + { + // This class is marked as disposable to support the BeginScope method. + // However, there is no need to dispose anything. + } + + public ILogger CreateLogger(string categoryName) => this; + + public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException(); +} diff --git a/dotnet/src/Shared/Throw/Throw.cs b/dotnet/src/Shared/Throw/Throw.cs index 7de459f145..5566034aba 100644 --- a/dotnet/src/Shared/Throw/Throw.cs +++ b/dotnet/src/Shared/Throw/Throw.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable IDE0005 // Using directive is unnecessary. + using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; diff --git a/dotnet/tests/.editorconfig b/dotnet/tests/.editorconfig index d8ab5b5399..25000c5400 100644 --- a/dotnet/tests/.editorconfig +++ b/dotnet/tests/.editorconfig @@ -1,4 +1,4 @@ -# Suppressing errors for Test projects under dotnet folder +# Suppressing errors for Test projects under dotnet/tests folder [*.cs] dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member