mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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>
This commit is contained in:
committed by
GitHub
Unverified
parent
d761c92a52
commit
2c75f13337
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a base class for test implementations that integrate with xUnit's <see cref="ITestOutputHelper"/> and
|
||||
/// logging infrastructure. This class also supports redirecting <see cref="System.Console"/> output to the test output
|
||||
/// for improved debugging and test output visibility.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="Output"/> property for writing test output and the <see cref="LoggerFactory"/> property for creating
|
||||
/// loggers.
|
||||
/// </remarks>
|
||||
public abstract class BaseSample : TextWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the output helper used for logging test results and diagnostic messages.
|
||||
/// </summary>
|
||||
protected ITestOutputHelper Output { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="ILoggerFactory"/> instance used to create loggers for logging operations.
|
||||
/// </summary>
|
||||
protected ILoggerFactory LoggerFactory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// This property makes the samples Console friendly. Allowing them to be copied and pasted into a Console app, with minimal changes.
|
||||
/// </summary>
|
||||
public BaseSample Console => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Encoding Encoding => System.Text.Encoding.UTF8;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BaseSample"/> class, setting up logging, configuration, and
|
||||
/// optionally redirecting <see cref="System.Console"/> output to the test output.
|
||||
/// </summary>
|
||||
/// <remarks>This constructor initializes logging using an <see cref="XunitLogger"/> and sets up
|
||||
/// configuration from multiple sources, including a JSON file, environment variables, and user secrets.
|
||||
/// If <paramref name="redirectSystemConsoleOutput"/> is <see langword="true"/>, calls to <see cref="System.Console"/>
|
||||
/// will be redirected to the test output provided by <paramref name="output"/>.
|
||||
/// </remarks>
|
||||
/// <param name="output">The <see cref="ITestOutputHelper"/> instance used to write test output.</param>
|
||||
/// <param name="redirectSystemConsoleOutput">
|
||||
/// A value indicating whether <see cref="System.Console"/> output should be redirected to the test output. <see langword="true"/> to redirect; otherwise, <see langword="false"/>.
|
||||
/// </param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a user message to the console.
|
||||
/// </summary>
|
||||
/// <param name="message">The text of the message to be sent. Cannot be null or empty.</param>
|
||||
protected void WriteUserMessage(string message)
|
||||
{
|
||||
this.WriteResponseOutput(new ChatResponse(new ChatMessage(ChatRole.User, message)), printUsage: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes and writes the latest agent chat response to the console, including metadata and content details.
|
||||
/// </summary>
|
||||
/// <remarks>This method formats and outputs the most recent message from the provided <see
|
||||
/// cref="ChatResponse"/> 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.</remarks>
|
||||
/// <param name="chatResponse">The <see cref="ChatResponse"/> object containing the chat messages and usage data.</param>
|
||||
/// <param name="printUsage">The flag to indicate whether to print usage information. Defaults to <see langword="true"/>.</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the streaming agent response updates to the console.
|
||||
/// </summary>
|
||||
/// <remarks>This method formats and outputs the most recent message from the provided <see
|
||||
/// cref="ChatResponseUpdate"/> 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.</remarks>
|
||||
/// <param name="update">The <see cref="ChatResponseUpdate"/> object containing the chat messages and usage data.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void WriteLine(object? value = null)
|
||||
=> this.Output.WriteLine(value ?? string.Empty);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void WriteLine(string? format, params object?[] arg)
|
||||
=> this.Output.WriteLine(format ?? string.Empty, arg);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void WriteLine(string? value)
|
||||
=> this.Output.WriteLine(value ?? string.Empty);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(object? value = null)
|
||||
=> this.Output.WriteLine(value ?? string.Empty);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(char[]? buffer)
|
||||
=> this.Output.WriteLine(new string(buffer));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Throw
|
||||
|
||||
Efficient sample project utilities.
|
||||
|
||||
To use this in your project, add the following to your `.csproj` file:
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<InjectSharedSamples>true</InjectSharedSamples>
|
||||
</PropertyGroup>
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Microsoft.Shared.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a centralized configuration management system for accessing application settings.
|
||||
/// </summary>
|
||||
public sealed class TestConfiguration
|
||||
{
|
||||
private readonly IConfigurationRoot _configRoot;
|
||||
private static TestConfiguration? s_instance;
|
||||
|
||||
private TestConfiguration(IConfigurationRoot configRoot)
|
||||
{
|
||||
this._configRoot = configRoot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the configuration system with the specified configuration root.
|
||||
/// </summary>
|
||||
/// <param name="configRoot">The root of the configuration hierarchy used to initialize the system. Must not be <see langword="null"/>.</param>
|
||||
public static void Initialize(IConfigurationRoot configRoot)
|
||||
{
|
||||
s_instance = new TestConfiguration(configRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the configuration root for the application.
|
||||
/// </summary>
|
||||
public static IConfigurationRoot? ConfigurationRoot => s_instance?._configRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration settings for the OpenAI integration.
|
||||
/// </summary>
|
||||
public static OpenAIConfig OpenAI => LoadSection<OpenAIConfig>();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a configuration section based on the specified key.
|
||||
/// </summary>
|
||||
/// <param name="caller">The key identifying the configuration section to retrieve. Cannot be null or empty.</param>
|
||||
/// <returns>The <see cref="IConfigurationSection"/> corresponding to the specified key.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the configuration root is not initialized or the specified key does not correspond to a valid section.</exception>
|
||||
public static IConfigurationSection GetSection(string caller)
|
||||
{
|
||||
return s_instance?._configRoot.GetSection(caller) ??
|
||||
throw new InvalidOperationException(caller);
|
||||
}
|
||||
|
||||
private static T LoadSection<T>([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<T>() ??
|
||||
throw new InvalidOperationException(caller);
|
||||
}
|
||||
|
||||
/// <summary>Represents the configuration settings required to interact with the OpenAI service.</summary>
|
||||
public class OpenAIConfig
|
||||
{
|
||||
/// <summary>Gets or sets the identifier for the chat completion model used in the application.</summary>
|
||||
public string? ChatModelId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the identifier for the embedding model used in the application.</summary>
|
||||
public string? EmbeddingModelId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the API key used for authentication with the OpenAI service.</summary>
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Shared.SampleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for <see cref="ITestOutputHelper"/> to make it more Console friendly.
|
||||
/// </summary>
|
||||
public static class TextOutputHelperExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="testOutputHelper">Target <see cref="ITestOutputHelper"/></param>
|
||||
/// <param name="target">Target object to write</param>
|
||||
public static void WriteLine(this ITestOutputHelper testOutputHelper, object target)
|
||||
{
|
||||
testOutputHelper.WriteLine(target.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="testOutputHelper">Target <see cref="ITestOutputHelper"/></param>
|
||||
public static void WriteLine(this ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
testOutputHelper.WriteLine(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="testOutputHelper">Target <see cref="ITestOutputHelper"/></param>
|
||||
public static void Write(this ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
testOutputHelper.WriteLine(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="testOutputHelper">Target <see cref="ITestOutputHelper"/></param>
|
||||
/// <param name="target">Target object to write</param>
|
||||
public static void Write(this ITestOutputHelper testOutputHelper, object target)
|
||||
{
|
||||
testOutputHelper.WriteLine(target.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Shared.SampleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// A logger that writes to the Xunit test output
|
||||
/// </summary>
|
||||
internal sealed class XunitLogger(ITestOutputHelper output) : ILoggerFactory, ILogger, IDisposable
|
||||
{
|
||||
private object? _scopeState;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var localState = state?.ToString();
|
||||
var line = this._scopeState is not null ? $"{this._scopeState} {localState}" : localState;
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
||||
{
|
||||
this._scopeState = state;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user