Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.NET client for protocol #85

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
8 changes: 4 additions & 4 deletions samples/backend/csharp/ChatProtocolBackend.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.11.4" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.6.0" />
<PackageReference Include="Microsoft.SemanticKernel" Version="1.7.1" />
<PackageReference Include="System.Text.Json" Version="8.0.4" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.7.0" />
<PackageReference Include="Microsoft.SemanticKernel" Version="1.26.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>

</Project>
17 changes: 16 additions & 1 deletion samples/backend/csharp/Model/AIChatCompletion.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<BinaryData>
{
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);
}
}
}
3 changes: 3 additions & 0 deletions samples/backend/csharp/Model/AIChatFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ namespace Backend.Model;

public struct AIChatFile
{
[JsonPropertyName("filename")]
public string Filename { get; set; }

[JsonPropertyName("contentType")]
public string ContentType { get; set; }

Expand Down
20 changes: 20 additions & 0 deletions samples/frontend/dotnet/Console/Console.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>c18ba783-dae7-4a3f-b550-8c2b6acfa1e4</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\..\sdk\dotnet\Client\Client.csproj" />
</ItemGroup>

</Project>
27 changes: 27 additions & 0 deletions samples/frontend/dotnet/Console/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Client.Extensions;

using Console;

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder b = new(args);
b.Configuration
.AddUserSecrets<Program>()
.AddEnvironmentVariables("AICHATPROTOCOL_")
.AddCommandLine(args);

b.Services
.AddHostedService<Repl>()
.AddAiChatProtocolClient();

CancellationTokenSource cancellationTokenSource = new();
System.Console.CancelKeyPress += (_, args) =>
{
cancellationTokenSource.Cancel();

args.Cancel = true;
};

await b.Build().RunAsync(cancellationTokenSource.Token);
59 changes: 59 additions & 0 deletions samples/frontend/dotnet/Console/Repl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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
{
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: ");

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 AIChatMessage { Content = message }]);
var response = await _protocolClient.CompleteAsync(request, cancellationToken);

Console.WriteLine($"Response: {response.Message.Content}");
Console.WriteLine();
}
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
89 changes: 89 additions & 0 deletions sdk/dotnet/Client/AIChatClient.cs
Original file line number Diff line number Diff line change
@@ -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<AIChatClientOptions> config, IHttpClientFactory factory, IOptions<JsonSerializerOptions> serializerOptions, ILogger<AiChatProtocolClient>? _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<AIChatCompletion?> 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<AIChatCompletion>(respString, _serializerOptions);
}
else
{
return await response.Content.ReadFromJsonAsync<AIChatCompletion>(_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);
}
}
37 changes: 37 additions & 0 deletions sdk/dotnet/Client/AIChatClientOptions.cs
Original file line number Diff line number Diff line change
@@ -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<AIChatClientOptions>, IPostConfigureOptions<AIChatClientOptions>, IValidateOptions<AIChatClientOptions>
{
public AIChatClientOptions(IConfiguration config, IOptions<JsonSerializerOptions> globalSerializerOptions, IOptions<JsonOptions> 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<JsonSerializerOptions>? _globalSerializerOptions;
private readonly IOptions<JsonOptions>? _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");
}
8 changes: 8 additions & 0 deletions sdk/dotnet/Client/Client.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\samples\backend\csharp\ChatProtocolBackend.csproj" />
</ItemGroup>
</Project>
35 changes: 35 additions & 0 deletions sdk/dotnet/Client/Extensions/DependencyInjectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<AIChatClientOptions>();

if (client is not null)
{
services.AddSingleton(client);
}
else
{
services.AddSingleton<IAiChatProtocolClient, AiChatProtocolClient>();
}

services.AddHttpClient(AiChatProtocolClient.HttpClientName, (sp, client) =>
{
var config = sp.GetRequiredService<IOptions<AIChatClientOptions>>().Value;
client.BaseAddress = config.ChatEndpointUri;
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
});

return services;
}
}
10 changes: 10 additions & 0 deletions sdk/dotnet/Client/Interfaces/IAiChatProtocolClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Client.Interfaces;

using Backend.Model;

using System.Threading;

public interface IAiChatProtocolClient
{
Task<AIChatCompletion?> CompleteAsync(AIChatRequest iChatRequest, CancellationToken cancellationToken = default);
}
4 changes: 4 additions & 0 deletions sdk/dotnet/Client/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Client.Tests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
9 changes: 9 additions & 0 deletions sdk/dotnet/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project>

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
11 changes: 11 additions & 0 deletions sdk/dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>

<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.0.0-preview.9.24525.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageVersion Include="System.Memory.Data" Version="8.0.1" />
</ItemGroup>
</Project>
Loading