From 877b281b32ca8626602132a583f168953f8a2b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brandon=F0=9F=8C=A9=EF=B8=8FH?= Date: Fri, 1 Nov 2024 09:43:26 -0700 Subject: [PATCH 1/5] bumping vulnerable packages --- samples/backend/csharp/ChatProtocolBackend.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/backend/csharp/ChatProtocolBackend.csproj b/samples/backend/csharp/ChatProtocolBackend.csproj index 69b7a54..d4950ff 100644 --- a/samples/backend/csharp/ChatProtocolBackend.csproj +++ b/samples/backend/csharp/ChatProtocolBackend.csproj @@ -7,10 +7,10 @@ - - - - + + + + From d962c5d86eb9174fc1e07b6ef75c83993b81865f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brandon=F0=9F=8C=A9=EF=B8=8FH?= Date: Fri, 1 Nov 2024 10:07:37 -0700 Subject: [PATCH 2/5] .NET client implementation for AI Chat Protocol --- .../backend/csharp/Model/AIChatCompletion.cs | 17 ++- samples/backend/csharp/Model/AIChatFile.cs | 3 + sdk/dotnet/Client/AIChatClient.cs | 89 ++++++++++++ sdk/dotnet/Client/AIChatClientOptions.cs | 37 +++++ sdk/dotnet/Client/Client.csproj | 8 ++ .../DependencyInjectionExtensions.cs | 35 +++++ .../Interfaces/IAiChatProtocolClient.cs | 10 ++ sdk/dotnet/Directory.Build.props | 9 ++ sdk/dotnet/Directory.Packages.props | 11 ++ .../Client.Tests/AiChatProtocolClientTests.cs | 131 ++++++++++++++++++ .../Tests/Client.Tests/Client.Tests.csproj | 8 ++ sdk/dotnet/Tests/Directory.Build.props | 20 +++ sdk/dotnet/Tests/Directory.Packages.props | 10 ++ sdk/dotnet/dotnet.sln | 54 ++++++++ 14 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 sdk/dotnet/Client/AIChatClient.cs create mode 100644 sdk/dotnet/Client/AIChatClientOptions.cs create mode 100644 sdk/dotnet/Client/Client.csproj create mode 100644 sdk/dotnet/Client/Extensions/DependencyInjectionExtensions.cs create mode 100644 sdk/dotnet/Client/Interfaces/IAiChatProtocolClient.cs create mode 100644 sdk/dotnet/Directory.Build.props create mode 100644 sdk/dotnet/Directory.Packages.props create mode 100644 sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs create mode 100644 sdk/dotnet/Tests/Client.Tests/Client.Tests.csproj create mode 100644 sdk/dotnet/Tests/Directory.Build.props create mode 100644 sdk/dotnet/Tests/Directory.Packages.props create mode 100644 sdk/dotnet/dotnet.sln diff --git a/samples/backend/csharp/Model/AIChatCompletion.cs b/samples/backend/csharp/Model/AIChatCompletion.cs index dba86cb..fd36513 100644 --- a/samples/backend/csharp/Model/AIChatCompletion.cs +++ b/samples/backend/csharp/Model/AIChatCompletion.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Text.Json; using System.Text.Json.Serialization; namespace Backend.Model; @@ -11,5 +12,19 @@ public record AIChatCompletion([property: JsonPropertyName("message")] AIChatMes public Guid? SessionState; [JsonInclude, JsonPropertyName("context"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public BinaryData? Context; + [JsonConverter(typeof(BinaryDataJsonConverter))] + public BinaryData? Context { get; set; } + + public class BinaryDataJsonConverter : JsonConverter + { + public override BinaryData? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType is JsonTokenType.String && reader.TryGetBytesFromBase64(out var b64bytes) + ? BinaryData.FromBytes(b64bytes) + : new BinaryData(reader.GetString() ?? string.Empty); + + public override void Write(Utf8JsonWriter writer, BinaryData value, JsonSerializerOptions options) + { + writer.WriteBase64StringValue(value); + } + } } diff --git a/samples/backend/csharp/Model/AIChatFile.cs b/samples/backend/csharp/Model/AIChatFile.cs index 50773c5..5fc2d7d 100644 --- a/samples/backend/csharp/Model/AIChatFile.cs +++ b/samples/backend/csharp/Model/AIChatFile.cs @@ -7,6 +7,9 @@ namespace Backend.Model; public struct AIChatFile { + [JsonPropertyName("filename")] + public string Filename { get; set; } + [JsonPropertyName("contentType")] public string ContentType { get; set; } diff --git a/sdk/dotnet/Client/AIChatClient.cs b/sdk/dotnet/Client/AIChatClient.cs new file mode 100644 index 0000000..f602d23 --- /dev/null +++ b/sdk/dotnet/Client/AIChatClient.cs @@ -0,0 +1,89 @@ +namespace Client; + +using Backend.Model; + +using Client.Interfaces; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +internal class AiChatProtocolClient(IOptions config, IHttpClientFactory factory, IOptions serializerOptions, ILogger? _log = null) : IAiChatProtocolClient +{ + public const string HttpClientName = "AIChatClient"; + + private readonly HttpClient _httpClient = ConfigureHttpClient(factory.CreateClient(HttpClientName), config.Value); + private readonly JsonSerializerOptions _serializerOptions = serializerOptions.Value; + + private static HttpClient ConfigureHttpClient(HttpClient client, AIChatClientOptions config) + { + if (client.BaseAddress is null) + { + client.BaseAddress = config.ChatEndpointUri; + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + return client; + } + + public async Task CompleteAsync(AIChatRequest request, CancellationToken cancellationToken) + { + _log?.LogTrace("Sending request to chat endpoint"); + var response = await _httpClient.PostAsync(string.Empty, CreateContent(request, cancellationToken), cancellationToken); + _log?.LogDebug("Received response from chat endpoint: {StatusCode}", response.StatusCode); + + response.EnsureSuccessStatusCode(); + + _log?.LogDebug("Deserializing response from chat endpoint"); + if (_log?.IsEnabled(LogLevel.Trace) is true) + { + var respString = await response.Content.ReadAsStringAsync(cancellationToken); + _log?.LogTrace("Response content: {Content}", respString); + + return JsonSerializer.Deserialize(respString, _serializerOptions); + } + else + { + return await response.Content.ReadFromJsonAsync(_serializerOptions, cancellationToken: cancellationToken); + } + } + + private static HttpContent CreateContent(AIChatRequest request, CancellationToken cancellationToken) + { + if (request.Messages.Any(message => message.Files?.Count is not null and not 0)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var boundary = $"---Part-{Guid.NewGuid()}"; + + // Strip off the Files from each message since we add them as parts to the form + var c = JsonContent.Create(request with { Messages = request.Messages.Select(message => message with { Files = null }).ToList() }); + c.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "json" }; + + var multiPartContent = new MultipartFormDataContent(boundary) { c }; + + foreach (var part in request.Messages + .SelectMany((message, index) => message.Files!.Select((file, fileIndex) => + new + { + Content = new ReadOnlyMemoryContent(file.Data) { Headers = { ContentType = new MediaTypeHeaderValue(file.ContentType) } }, + Name = $"messages[{index}].files[{fileIndex}]", + file.Filename, + })) + .Where(part => part is not null)) + { + cancellationToken.ThrowIfCancellationRequested(); + + multiPartContent.Add(part.Content, part.Name, part.Filename); + } + + return multiPartContent; + } + + return JsonContent.Create(request); + } +} diff --git a/sdk/dotnet/Client/AIChatClientOptions.cs b/sdk/dotnet/Client/AIChatClientOptions.cs new file mode 100644 index 0000000..525b1ee --- /dev/null +++ b/sdk/dotnet/Client/AIChatClientOptions.cs @@ -0,0 +1,37 @@ +namespace Client; + +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +using System; +using System.Text.Json; + +using static Backend.Model.AIChatCompletion; + +public class AIChatClientOptions() : IConfigureOptions, IPostConfigureOptions, IValidateOptions +{ + public AIChatClientOptions(IConfiguration config, IOptions globalSerializerOptions, IOptions httpJsonOptions) : this() + { + _config = config; + _globalSerializerOptions = globalSerializerOptions; + _httpJsonOptions = httpJsonOptions; + } + + required public Uri ChatEndpointUri { get; init; } + + private static readonly BinaryDataJsonConverter converter = new(); + private readonly IConfiguration? _config; + private readonly IOptions? _globalSerializerOptions; + private readonly IOptions? _httpJsonOptions; + + public void Configure(AIChatClientOptions options) => _config?.GetRequiredSection("AiChatProtocol").Bind(options); + + public void PostConfigure(string? name, AIChatClientOptions options) + { + _globalSerializerOptions?.Value.Converters.Add(converter); + _httpJsonOptions?.Value.SerializerOptions.Converters.Add(converter); + } + + public ValidateOptionsResult Validate(string? name, AIChatClientOptions options) => options.ChatEndpointUri is not null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail("ChatEndpointUri must be set"); +} diff --git a/sdk/dotnet/Client/Client.csproj b/sdk/dotnet/Client/Client.csproj new file mode 100644 index 0000000..91c00c5 --- /dev/null +++ b/sdk/dotnet/Client/Client.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/sdk/dotnet/Client/Extensions/DependencyInjectionExtensions.cs b/sdk/dotnet/Client/Extensions/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..ddab531 --- /dev/null +++ b/sdk/dotnet/Client/Extensions/DependencyInjectionExtensions.cs @@ -0,0 +1,35 @@ +namespace Client.Extensions; + +using Client.Interfaces; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Net.Http.Headers; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddAiChatProtocolClient(this IServiceCollection services, IAiChatProtocolClient? client = null) + { + services.ConfigureOptions(); + + if (client is not null) + { + services.AddSingleton(client); + } + else + { + services.AddSingleton(); + } + + services.AddHttpClient(AiChatProtocolClient.HttpClientName, (sp, client) => + { + var config = sp.GetRequiredService>().Value; + client.BaseAddress = config.ChatEndpointUri; + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + }); + + return services; + } +} diff --git a/sdk/dotnet/Client/Interfaces/IAiChatProtocolClient.cs b/sdk/dotnet/Client/Interfaces/IAiChatProtocolClient.cs new file mode 100644 index 0000000..d372805 --- /dev/null +++ b/sdk/dotnet/Client/Interfaces/IAiChatProtocolClient.cs @@ -0,0 +1,10 @@ +namespace Client.Interfaces; + +using Backend.Model; + +using System.Threading; + +public interface IAiChatProtocolClient +{ + Task CompleteAsync(AIChatRequest iChatRequest, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/sdk/dotnet/Directory.Build.props b/sdk/dotnet/Directory.Build.props new file mode 100644 index 0000000..75aba62 --- /dev/null +++ b/sdk/dotnet/Directory.Build.props @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + \ No newline at end of file diff --git a/sdk/dotnet/Directory.Packages.props b/sdk/dotnet/Directory.Packages.props new file mode 100644 index 0000000..3c9c3cb --- /dev/null +++ b/sdk/dotnet/Directory.Packages.props @@ -0,0 +1,11 @@ + + + true + + + + + + + + \ No newline at end of file diff --git a/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs b/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs new file mode 100644 index 0000000..01adb56 --- /dev/null +++ b/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs @@ -0,0 +1,131 @@ +namespace Client.Tests; + +using Backend.Model; + +using Client; + +using Moq; +using Moq.AutoMock; +using Moq.Protected; + +using System.Net; +using System.Net.Http.Json; + +public class AiChatProtocolClientTests +{ + private readonly AIChatClientOptions _config = new() + { + ChatEndpointUri = new Uri("https://api.example.com/chat") + }; + + private static readonly AutoMocker mock = new(); + + private static void RegisterFactoryWithMockedHttpResponse(HttpResponseMessage expectedResponse, Action? requestValidator = null) + { + var handlerMock = mock.GetMock(enablePrivate: true); + handlerMock.Protected().Setup("Send", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((HttpRequestMessage req, CancellationToken _) => + { + requestValidator?.Invoke(req); + return expectedResponse; + }); + + handlerMock.Protected().Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage req, CancellationToken _) => + { + requestValidator?.Invoke(req); + return expectedResponse; + }); + + mock.Use(new HttpClient(handlerMock.Object)); + } + + public AiChatProtocolClientTests() => mock.Use(_config); + + [Fact] + public async Task CompleteAsync_ShouldReturnAIChatCompletion_WhenRequestIsSuccessful() + { + var request = new AIChatRequest([]); + var expectedResponse = new AIChatCompletion(new AIChatMessage() { Content = "hi" }); + + RegisterFactoryWithMockedHttpResponse(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(expectedResponse) + }); + + var client = mock.CreateInstance(); + + var result = await client.CompleteAsync(request, default); + + Assert.NotNull(result); + Assert.Equal(expectedResponse, result); + } + + [Fact] + public async Task CompleteAsync_ShouldThrowException_WhenRequestFails() + { + RegisterFactoryWithMockedHttpResponse(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest + }); + + var client = mock.Get(); + + await Assert.ThrowsAsync(() => client.CompleteAsync(new([]), default)); + } + + [Fact] + public async Task CompleteAsync_WithFilesInRequest_IsSuccessful() + { + // Arrange + var expectedResponse = new AIChatCompletion(new AIChatMessage() { Content = "hi" }); + var chatRequest = new AIChatRequest([ + new() + { + Content = "Analyze this file for me", + Files = + [ + new AIChatFile { Data = new([ 1, 2, 3 ]), ContentType = "application/octet-stream", Filename = "test.txt" } + ] + } + ]) + { SessionState = Guid.NewGuid() }; + + RegisterFactoryWithMockedHttpResponse(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(expectedResponse) + }, async (HttpRequestMessage r) => + { + var content = r.Content as MultipartFormDataContent; + Assert.NotNull(content); + Assert.Equal(2, content.Count()); + + var jsonPart = content.First() as JsonContent; + Assert.NotNull(jsonPart); + Assert.Equal(typeof(AIChatRequest), jsonPart.ObjectType); + + // The json value in the multipart form should not contain the files + Assert.Equivalent(chatRequest with { Messages = chatRequest.Messages.Select(i => i with { Files = null }).ToList() }, jsonPart.Value); + + var fileFormContent = content.Skip(1).First() as ReadOnlyMemoryContent; + Assert.NotNull(fileFormContent); + Assert.Equal("application/octet-stream", fileFormContent.Headers.ContentType?.MediaType); + + var disp = fileFormContent.Headers.ContentDisposition; + Assert.Equal("test.txt", disp?.FileName); + Assert.Equal("test.txt", disp?.FileNameStar); + Assert.Equal(@"""messages[0].files[0]""", disp?.Name); + Assert.Equal(3, disp?.Parameters.Count); + Assert.Equal([1, 2, 3], await fileFormContent.ReadAsByteArrayAsync()); + }); + + var client = mock.CreateInstance(); + + var result = await client.CompleteAsync(chatRequest, default); + + Assert.NotNull(result); + Assert.Equal(expectedResponse, result); + } +} diff --git a/sdk/dotnet/Tests/Client.Tests/Client.Tests.csproj b/sdk/dotnet/Tests/Client.Tests/Client.Tests.csproj new file mode 100644 index 0000000..7484d5f --- /dev/null +++ b/sdk/dotnet/Tests/Client.Tests/Client.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/sdk/dotnet/Tests/Directory.Build.props b/sdk/dotnet/Tests/Directory.Build.props new file mode 100644 index 0000000..12d3319 --- /dev/null +++ b/sdk/dotnet/Tests/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + false + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sdk/dotnet/Tests/Directory.Packages.props b/sdk/dotnet/Tests/Directory.Packages.props new file mode 100644 index 0000000..0326d50 --- /dev/null +++ b/sdk/dotnet/Tests/Directory.Packages.props @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/sdk/dotnet/dotnet.sln b/sdk/dotnet/dotnet.sln new file mode 100644 index 0000000..a882efb --- /dev/null +++ b/sdk/dotnet/dotnet.sln @@ -0,0 +1,54 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client", "Client\Client.csproj", "{AD754304-75EF-4A0C-A785-A4C68B901EAE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{1A4D2496-4669-4EB2-9669-05E15954447F}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChatProtocolBackend", "..\..\samples\backend\csharp\ChatProtocolBackend.csproj", "{4D9259DE-46D5-4C03-92CB-C82A952E3D2A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Tests", "Tests\Client.Tests\Client.Tests.csproj", "{E4281AF2-D178-46EC-9ABE-908B2FF6F01C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{31CAADBE-1FF2-450B-9A8E-4EE517F4F60A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "..\..\samples\frontend\dotnet\Console\Console.csproj", "{B159000D-1A1E-43C9-ABF8-F4A64971E95F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AD754304-75EF-4A0C-A785-A4C68B901EAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD754304-75EF-4A0C-A785-A4C68B901EAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD754304-75EF-4A0C-A785-A4C68B901EAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD754304-75EF-4A0C-A785-A4C68B901EAE}.Release|Any CPU.Build.0 = Release|Any CPU + {4D9259DE-46D5-4C03-92CB-C82A952E3D2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D9259DE-46D5-4C03-92CB-C82A952E3D2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D9259DE-46D5-4C03-92CB-C82A952E3D2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D9259DE-46D5-4C03-92CB-C82A952E3D2A}.Release|Any CPU.Build.0 = Release|Any CPU + {E4281AF2-D178-46EC-9ABE-908B2FF6F01C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4281AF2-D178-46EC-9ABE-908B2FF6F01C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4281AF2-D178-46EC-9ABE-908B2FF6F01C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4281AF2-D178-46EC-9ABE-908B2FF6F01C}.Release|Any CPU.Build.0 = Release|Any CPU + {B159000D-1A1E-43C9-ABF8-F4A64971E95F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B159000D-1A1E-43C9-ABF8-F4A64971E95F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B159000D-1A1E-43C9-ABF8-F4A64971E95F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B159000D-1A1E-43C9-ABF8-F4A64971E95F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B159000D-1A1E-43C9-ABF8-F4A64971E95F} = {31CAADBE-1FF2-450B-9A8E-4EE517F4F60A} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0D6B5B21-972F-482B-B9F4-9020FD9C94AE} + EndGlobalSection +EndGlobal From 9c24c4617033b7b2d7d1636a6699e364f5ab28b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brandon=F0=9F=8C=A9=EF=B8=8FH?= Date: Fri, 1 Nov 2024 14:12:43 -0700 Subject: [PATCH 3/5] adding .net client sample --- .../frontend/dotnet/Console/Console.csproj | 20 +++++++++++ samples/frontend/dotnet/Console/Program.cs | 19 ++++++++++ samples/frontend/dotnet/Console/Repl.cs | 36 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 samples/frontend/dotnet/Console/Console.csproj create mode 100644 samples/frontend/dotnet/Console/Program.cs create mode 100644 samples/frontend/dotnet/Console/Repl.cs diff --git a/samples/frontend/dotnet/Console/Console.csproj b/samples/frontend/dotnet/Console/Console.csproj new file mode 100644 index 0000000..6df9592 --- /dev/null +++ b/samples/frontend/dotnet/Console/Console.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + c18ba783-dae7-4a3f-b550-8c2b6acfa1e4 + + + + + + + + + + + + diff --git a/samples/frontend/dotnet/Console/Program.cs b/samples/frontend/dotnet/Console/Program.cs new file mode 100644 index 0000000..30b4318 --- /dev/null +++ b/samples/frontend/dotnet/Console/Program.cs @@ -0,0 +1,19 @@ +using Client.Extensions; + +using Console; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder b = new(args); +b.Configuration + .AddUserSecrets() + .AddEnvironmentVariables("AICHATPROTOCOL_") + .AddCommandLine(args); + +b.Services + .AddHostedService() + .AddAiChatProtocolClient(); + +b.Build().Run(); diff --git a/samples/frontend/dotnet/Console/Repl.cs b/samples/frontend/dotnet/Console/Repl.cs new file mode 100644 index 0000000..5f0171f --- /dev/null +++ b/samples/frontend/dotnet/Console/Repl.cs @@ -0,0 +1,36 @@ +namespace Console; + +using Backend.Model; + +using Client.Interfaces; + +using Microsoft.Extensions.Hosting; + +using System; +using System.Threading; +using System.Threading.Tasks; + +internal class Repl(IAiChatProtocolClient _protocolClient) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + Console.Write("Enter your message: "); + var message = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(message)) + { + Console.WriteLine("Message cannot be empty."); + continue; + } + + var request = new AIChatRequest([new() { Content = message }]); + var response = await _protocolClient.CompleteAsync(request, cancellationToken); + + Console.WriteLine($"Response: {response.Message.Content}"); + Console.WriteLine(); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} From c2bda458b812c0cbba8ab59b0d292cb6272b1991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brandon=F0=9F=8C=A9=EF=B8=8FH?= Date: Fri, 1 Nov 2024 14:25:36 -0700 Subject: [PATCH 4/5] Fixing UT execution --- sdk/dotnet/Client/Properties/AssemblyInfo.cs | 4 ++++ .../Tests/Client.Tests/AiChatProtocolClientTests.cs | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 sdk/dotnet/Client/Properties/AssemblyInfo.cs diff --git a/sdk/dotnet/Client/Properties/AssemblyInfo.cs b/sdk/dotnet/Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..5d7769c --- /dev/null +++ b/sdk/dotnet/Client/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Client.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs b/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs index 01adb56..ef50a05 100644 --- a/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs +++ b/sdk/dotnet/Tests/Client.Tests/AiChatProtocolClientTests.cs @@ -4,6 +4,8 @@ namespace Client.Tests; using Client; +using Microsoft.Extensions.Options; + using Moq; using Moq.AutoMock; using Moq.Protected; @@ -37,10 +39,11 @@ private static void RegisterFactoryWithMockedHttpResponse(HttpResponseMessage ex return expectedResponse; }); - mock.Use(new HttpClient(handlerMock.Object)); + mock.GetMock().Setup(f => f.CreateClient(AiChatProtocolClient.HttpClientName)) + .Returns(() => new HttpClient(handlerMock.Object)); } - public AiChatProtocolClientTests() => mock.Use(_config); + public AiChatProtocolClientTests() => mock.Use(Options.Create(_config)); [Fact] public async Task CompleteAsync_ShouldReturnAIChatCompletion_WhenRequestIsSuccessful() @@ -70,7 +73,7 @@ public async Task CompleteAsync_ShouldThrowException_WhenRequestFails() StatusCode = HttpStatusCode.BadRequest }); - var client = mock.Get(); + var client = mock.CreateInstance(); await Assert.ThrowsAsync(() => client.CompleteAsync(new([]), default)); } From ec4a4f1e485c41449aad1806f9ce062b7f3ca7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brandon=F0=9F=8C=A9=EF=B8=8FH?= Date: Fri, 1 Nov 2024 15:11:42 -0700 Subject: [PATCH 5/5] Getting cancellation to work cleanly in the sample --- samples/frontend/dotnet/Console/Program.cs | 10 +++++++- samples/frontend/dotnet/Console/Repl.cs | 27 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/samples/frontend/dotnet/Console/Program.cs b/samples/frontend/dotnet/Console/Program.cs index 30b4318..ea98135 100644 --- a/samples/frontend/dotnet/Console/Program.cs +++ b/samples/frontend/dotnet/Console/Program.cs @@ -16,4 +16,12 @@ .AddHostedService() .AddAiChatProtocolClient(); -b.Build().Run(); +CancellationTokenSource cancellationTokenSource = new(); +System.Console.CancelKeyPress += (_, args) => +{ + cancellationTokenSource.Cancel(); + + args.Cancel = true; +}; + +await b.Build().RunAsync(cancellationTokenSource.Token); diff --git a/samples/frontend/dotnet/Console/Repl.cs b/samples/frontend/dotnet/Console/Repl.cs index 5f0171f..2cc8d59 100644 --- a/samples/frontend/dotnet/Console/Repl.cs +++ b/samples/frontend/dotnet/Console/Repl.cs @@ -12,19 +12,42 @@ internal class Repl(IAiChatProtocolClient _protocolClient) : IHostedService { + private readonly TaskCompletionSource _cts = new(); + public async Task StartAsync(CancellationToken cancellationToken) { + var cancelTask = Task.Delay(Timeout.Infinite, cancellationToken); while (!cancellationToken.IsCancellationRequested) { Console.Write("Enter your message: "); - var message = Console.ReadLine(); + + string? message = null; + do + { + var readTask = Task.Run(Console.ReadLine, cancellationToken); + var completedTask = await Task.WhenAny(readTask, cancelTask); + if (completedTask == cancelTask) + { + // Cancellation was requested + break; + } + + message = await readTask; + } while (!cancellationToken.IsCancellationRequested && string.IsNullOrWhiteSpace(message)); + + if (cancellationToken.IsCancellationRequested) + { + // Cancellation was requested + break; + } + if (string.IsNullOrWhiteSpace(message)) { Console.WriteLine("Message cannot be empty."); continue; } - var request = new AIChatRequest([new() { Content = message }]); + var request = new AIChatRequest([new AIChatMessage { Content = message }]); var response = await _protocolClient.CompleteAsync(request, cancellationToken); Console.WriteLine($"Response: {response.Message.Content}");