From 468ed8753c36231da118f8d156b9ad49beae8bf6 Mon Sep 17 00:00:00 2001 From: Jon Galloway Date: Wed, 26 Feb 2025 13:08:03 -0800 Subject: [PATCH] Update to use Microsoft.Extensions.AI.Abstractions Fixes #38 Replace `IInferenceBackend` with `IChatClient` and remove `SmartComponents.Inference.OpenAI`. * **Project Files:** - Add a package reference to `Microsoft.Extensions.AI.Abstractions` in `samples/ExampleBlazorApp/ExampleBlazorApp.csproj` and `samples/ExampleMvcRazorPagesApp/ExampleMvcRazorPagesApp.csproj`. - Remove the `SmartComponents.Inference.OpenAI` project reference from both project files. * **Program Files:** - Replace the usage of `IInferenceBackend` with `IChatClient` in `samples/ExampleBlazorApp/Program.cs` and `samples/ExampleMvcRazorPagesApp/Program.cs`. - Remove the `WithInferenceBackend` method call from both program files. * **ASP.NET Core Files:** - Replace the usage of `IInferenceBackend` with `IChatClient` in `src/SmartComponents.AspNetCore/DefaultSmartComponentsBuilder.cs`, `src/SmartComponents.AspNetCore/ISmartComponentsBuilder.cs`, and `src/SmartComponents.AspNetCore/SmartComponentsServiceCollectionExtensions.cs`. * **Remove Files:** - Delete `src/SmartComponents.Inference/IInferenceBackend.cs`. - Delete `src/SmartComponents.Inference.OpenAI/ApiConfig.cs`, `src/SmartComponents.Inference.OpenAI/OpenAIInferenceBackend.cs`, `src/SmartComponents.Inference.OpenAI/ResponseCache.cs`, `src/SmartComponents.Inference.OpenAI/SelfHostedLlmTransport.cs`, and `src/SmartComponents.Inference.OpenAI/SmartComponents.Inference.OpenAI.csproj`. --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/dotnet/smartcomponents/issues/38?shareId=XXXX-XXXX-XXXX-XXXX). --- .../ExampleBlazorApp/ExampleBlazorApp.csproj | 2 +- samples/ExampleBlazorApp/Program.cs | 3 +- .../ExampleMvcRazorPagesApp.csproj | 2 +- samples/ExampleMvcRazorPagesApp/Program.cs | 5 +- .../DefaultSmartComponentsBuilder.cs | 8 +- .../ISmartComponentsBuilder.cs | 6 +- ...rtComponentsServiceCollectionExtensions.cs | 8 +- .../ApiConfig.cs | 46 ------ .../OpenAIInferenceBackend.cs | 89 ------------ .../ResponseCache.cs | 134 ------------------ .../SelfHostedLlmTransport.cs | 24 ---- .../SmartComponents.Inference.OpenAI.csproj | 16 --- .../IInferenceBackend.cs | 11 -- 13 files changed, 16 insertions(+), 338 deletions(-) delete mode 100644 src/SmartComponents.Inference.OpenAI/ApiConfig.cs delete mode 100644 src/SmartComponents.Inference.OpenAI/OpenAIInferenceBackend.cs delete mode 100644 src/SmartComponents.Inference.OpenAI/ResponseCache.cs delete mode 100644 src/SmartComponents.Inference.OpenAI/SelfHostedLlmTransport.cs delete mode 100644 src/SmartComponents.Inference.OpenAI/SmartComponents.Inference.OpenAI.csproj delete mode 100644 src/SmartComponents.Inference/IInferenceBackend.cs diff --git a/samples/ExampleBlazorApp/ExampleBlazorApp.csproj b/samples/ExampleBlazorApp/ExampleBlazorApp.csproj index 0699f52..1c1c3fc 100644 --- a/samples/ExampleBlazorApp/ExampleBlazorApp.csproj +++ b/samples/ExampleBlazorApp/ExampleBlazorApp.csproj @@ -12,8 +12,8 @@ - + diff --git a/samples/ExampleBlazorApp/Program.cs b/samples/ExampleBlazorApp/Program.cs index 16aeed6..9291a1f 100644 --- a/samples/ExampleBlazorApp/Program.cs +++ b/samples/ExampleBlazorApp/Program.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using ExampleBlazorApp.Components; -using SmartComponents.Inference.OpenAI; +using Microsoft.Extensions.AI.Abstractions; using SmartComponents.LocalEmbeddings; var builder = WebApplication.CreateBuilder(args); @@ -12,7 +12,6 @@ builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); builder.Services.AddSmartComponents() - .WithInferenceBackend() .WithAntiforgeryValidation(); builder.Services.AddSingleton(); diff --git a/samples/ExampleMvcRazorPagesApp/ExampleMvcRazorPagesApp.csproj b/samples/ExampleMvcRazorPagesApp/ExampleMvcRazorPagesApp.csproj index db40a57..ff3e10b 100644 --- a/samples/ExampleMvcRazorPagesApp/ExampleMvcRazorPagesApp.csproj +++ b/samples/ExampleMvcRazorPagesApp/ExampleMvcRazorPagesApp.csproj @@ -9,8 +9,8 @@ - + diff --git a/samples/ExampleMvcRazorPagesApp/Program.cs b/samples/ExampleMvcRazorPagesApp/Program.cs index 024507b..91fca00 100644 --- a/samples/ExampleMvcRazorPagesApp/Program.cs +++ b/samples/ExampleMvcRazorPagesApp/Program.cs @@ -1,7 +1,7 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using SmartComponents.Inference.OpenAI; +using Microsoft.Extensions.AI.Abstractions; using SmartComponents.LocalEmbeddings; var builder = WebApplication.CreateBuilder(args); @@ -11,7 +11,6 @@ builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); builder.Services.AddSmartComponents() - .WithInferenceBackend() .WithAntiforgeryValidation(); builder.Services.AddSingleton(); diff --git a/src/SmartComponents.AspNetCore/DefaultSmartComponentsBuilder.cs b/src/SmartComponents.AspNetCore/DefaultSmartComponentsBuilder.cs index 607b98b..5b5ed2c 100644 --- a/src/SmartComponents.AspNetCore/DefaultSmartComponentsBuilder.cs +++ b/src/SmartComponents.AspNetCore/DefaultSmartComponentsBuilder.cs @@ -2,21 +2,21 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; -using SmartComponents.StaticAssets.Inference; +using Microsoft.Extensions.AI.Abstractions; namespace Microsoft.AspNetCore.Builder; internal sealed class DefaultSmartComponentsBuilder(IServiceCollection services) : ISmartComponentsBuilder { - public ISmartComponentsBuilder WithInferenceBackend(string? name) where T : class, IInferenceBackend + public ISmartComponentsBuilder WithInferenceBackend(string? name) where T : class, IChatClient { if (string.IsNullOrEmpty(name)) { - services.AddSingleton(); + services.AddSingleton(); } else { - services.AddKeyedSingleton(name); + services.AddKeyedSingleton(name); } return this; diff --git a/src/SmartComponents.AspNetCore/ISmartComponentsBuilder.cs b/src/SmartComponents.AspNetCore/ISmartComponentsBuilder.cs index 4486137..4a2b46f 100644 --- a/src/SmartComponents.AspNetCore/ISmartComponentsBuilder.cs +++ b/src/SmartComponents.AspNetCore/ISmartComponentsBuilder.cs @@ -1,13 +1,13 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using SmartComponents.StaticAssets.Inference; +using Microsoft.Extensions.AI.Abstractions; namespace Microsoft.AspNetCore.Builder; public interface ISmartComponentsBuilder { - public ISmartComponentsBuilder WithInferenceBackend(string? name = null) where T : class, IInferenceBackend; + public ISmartComponentsBuilder WithInferenceBackend(string? name = null) where T : class, IChatClient; public ISmartComponentsBuilder WithAntiforgeryValidation(); } diff --git a/src/SmartComponents.AspNetCore/SmartComponentsServiceCollectionExtensions.cs b/src/SmartComponents.AspNetCore/SmartComponentsServiceCollectionExtensions.cs index 9258818..48b4f4e 100644 --- a/src/SmartComponents.AspNetCore/SmartComponentsServiceCollectionExtensions.cs +++ b/src/SmartComponents.AspNetCore/SmartComponentsServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; @@ -9,10 +9,10 @@ using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.AI.Abstractions; using SmartComponents.AspNetCore; using SmartComponents.Inference; using SmartComponents.Infrastructure; -using SmartComponents.StaticAssets.Inference; namespace Microsoft.AspNetCore.Builder; @@ -40,7 +40,7 @@ public Action Configure(Action next) = builder.UseEndpoints(app => { - var smartPasteEndpoint = app.MapPost("/_smartcomponents/smartpaste", async ([FromServices] IInferenceBackend inference, HttpContext httpContext, [FromServices] IAntiforgery antiforgery, [FromServices] SmartPasteInference smartPasteInference) => + var smartPasteEndpoint = app.MapPost("/_smartcomponents/smartpaste", async ([FromServices] IChatClient inference, HttpContext httpContext, [FromServices] IAntiforgery antiforgery, [FromServices] SmartPasteInference smartPasteInference) => { // The rules about whether antiforgery are enabled by default vary across different // ASP.NET Core versions. To make it consistent, we disable the default enablement on @@ -62,7 +62,7 @@ public Action Configure(Action next) = return result.BadRequest ? Results.BadRequest() : Results.Content(result.Response!); }); - var smartTextAreaEndpoint = app.MapPost("/_smartcomponents/smarttextarea", async ([FromServices] IInferenceBackend inference, HttpContext httpContext, [FromServices] IAntiforgery antiforgery, [FromServices] SmartTextAreaInference smartTextAreaInference) => + var smartTextAreaEndpoint = app.MapPost("/_smartcomponents/smarttextarea", async ([FromServices] IChatClient inference, HttpContext httpContext, [FromServices] IAntiforgery antiforgery, [FromServices] SmartTextAreaInference smartTextAreaInference) => { if (validateAntiforgery) { diff --git a/src/SmartComponents.Inference.OpenAI/ApiConfig.cs b/src/SmartComponents.Inference.OpenAI/ApiConfig.cs deleted file mode 100644 index 5a2cc86..0000000 --- a/src/SmartComponents.Inference.OpenAI/ApiConfig.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using Microsoft.Extensions.Configuration; - -namespace SmartComponents.Inference.OpenAI; - -internal class ApiConfig -{ - public string? ApiKey { get; } - public string? DeploymentName { get; } - public Uri? Endpoint { get; } - public bool SelfHosted { get; } - - public ApiConfig(IConfiguration config) - { - var configSection = config.GetRequiredSection("SmartComponents"); - - SelfHosted = configSection.GetValue("SelfHosted") ?? false; - - if (SelfHosted) - { - Endpoint = configSection.GetValue("Endpoint") - ?? throw new InvalidOperationException("Missing required configuration value: SmartComponents:Endpoint. This is required for SelfHosted inference."); - - // Ollama uses this, but other self-hosted backends might not, so it's optional. - DeploymentName = configSection.GetValue("DeploymentName"); - - // Ollama doesn't use this, but other self-hosted backends might do, so it's optional. - ApiKey = configSection.GetValue("ApiKey"); - } - else - { - // If set, we assume Azure OpenAI. If not, we assume OpenAI. - Endpoint = configSection.GetValue("Endpoint"); - - // For Azure OpenAI, it's your deployment name. For OpenAI, it's the model name. - DeploymentName = configSection.GetValue("DeploymentName") - ?? throw new InvalidOperationException("Missing required configuration value: SmartComponents:DeploymentName"); - - ApiKey = configSection.GetValue("ApiKey") - ?? throw new InvalidOperationException("Missing required configuration value: SmartComponents:ApiKey"); - } - } -} diff --git a/src/SmartComponents.Inference.OpenAI/OpenAIInferenceBackend.cs b/src/SmartComponents.Inference.OpenAI/OpenAIInferenceBackend.cs deleted file mode 100644 index faa7cf2..0000000 --- a/src/SmartComponents.Inference.OpenAI/OpenAIInferenceBackend.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Linq; -using System.Threading.Tasks; -using Azure; -using Azure.AI.OpenAI; -using Microsoft.Extensions.Configuration; -using SmartComponents.StaticAssets.Inference; - -namespace SmartComponents.Inference.OpenAI; - -public class OpenAIInferenceBackend(IConfiguration configuration) - : IInferenceBackend -{ - public async Task GetChatResponseAsync(ChatParameters options) - { -#if DEBUG - if (ResponseCache.TryGetCachedResponse(options, out var cachedResponse)) - { - return cachedResponse!; - } -#endif - - var apiConfig = new ApiConfig(configuration); - var client = CreateClient(apiConfig); - var chatCompletionsOptions = new ChatCompletionsOptions - { - DeploymentName = apiConfig.DeploymentName, - Temperature = options.Temperature ?? 0f, - NucleusSamplingFactor = options.TopP ?? 1, - MaxTokens = options.MaxTokens ?? 200, - FrequencyPenalty = options.FrequencyPenalty ?? 0, - PresencePenalty = options.PresencePenalty ?? 0, - ResponseFormat = options.RespondJson ? ChatCompletionsResponseFormat.JsonObject : ChatCompletionsResponseFormat.Text, - }; - - foreach (var message in options.Messages ?? Enumerable.Empty()) - { - chatCompletionsOptions.Messages.Add(message.Role switch - { - ChatMessageRole.System => new ChatRequestSystemMessage(message.Text), - ChatMessageRole.User => new ChatRequestUserMessage(message.Text), - ChatMessageRole.Assistant => new ChatRequestAssistantMessage(message.Text), - _ => throw new InvalidOperationException($"Unknown chat message role: {message.Role}") - }); - } - - if (options.StopSequences is { } stopSequences) - { - foreach (var stopSequence in stopSequences) - { - chatCompletionsOptions.StopSequences.Add(stopSequence); - } - } - - var completionsResponse = await client.GetChatCompletionsAsync(chatCompletionsOptions); - - var response = completionsResponse.Value.Choices.FirstOrDefault()?.Message.Content ?? string.Empty; - -#if DEBUG - ResponseCache.SetCachedResponse(options, response); -#endif - - return response; - } - - private static OpenAIClient CreateClient(ApiConfig apiConfig) - { - if (apiConfig.SelfHosted) - { - var transport = new SelfHostedLlmTransport(apiConfig.Endpoint!); - return new OpenAIClient(apiConfig.ApiKey, new() { Transport = transport }); - } - else if (apiConfig.Endpoint is null) - { - // OpenAI - return new OpenAIClient(apiConfig.ApiKey); - } - else - { - // Azure OpenAI - return new OpenAIClient( - apiConfig.Endpoint, - new AzureKeyCredential(apiConfig.ApiKey!)); - } - } -} diff --git a/src/SmartComponents.Inference.OpenAI/ResponseCache.cs b/src/SmartComponents.Inference.OpenAI/ResponseCache.cs deleted file mode 100644 index 9435b4d..0000000 --- a/src/SmartComponents.Inference.OpenAI/ResponseCache.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if DEBUG -using System; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using SmartComponents.StaticAssets.Inference; - -namespace SmartComponents.Inference.OpenAI; - -// This is primarily so that E2E tests running in CI don't have to call OpenAI for real, so that: -// [1] We don't have to make the API keys available to CI -// [2] There's no risk of random failures due to network issues or the nondeterminism of the AI responses -// It will not be used in real apps in production. Its other benefit is reducing OpenAI usage during local development. - -internal static class ResponseCache -{ - static bool IsEnabled = Environment.GetEnvironmentVariable("SMARTCOMPONENTS_E2E_TEST") == "true"; - - readonly static Lazy CacheDir = new(() => - { - var dir = Path.Combine(GetSolutionDirectory(), "test", "CachedResponses"); - Directory.CreateDirectory(dir); - return dir; - }); - - public static bool TryGetCachedResponse(ChatParameters request, out string? response) - { - if (!IsEnabled) - { - response = null; - return false; - } - - var filePath = GetCacheFilePath(request); - if (File.Exists(filePath)) - { - Console.WriteLine("Using cached response for " + Path.GetFileName(filePath)); - response = File.ReadAllText(filePath); - return true; - } - else - { - Console.WriteLine("Did not find cached response for " + Path.GetFileName(filePath)); - response = null; - return false; - } - } - - public static void SetCachedResponse(ChatParameters request, string response) - { - if (IsEnabled) - { - var filePath = GetCacheFilePath(request); - File.WriteAllText(filePath, response); - File.WriteAllText(filePath.Replace(".response.txt", ".request.json"), GetCacheKeyInput(request)); - } - } - - private static string GetCacheFilePath(ChatParameters request) - => GetCacheFilePath(request, request.Messages.LastOrDefault()?.Text ?? "no_messages"); - - private static string GetCacheFilePath(T request, string summary) - => Path.Combine(CacheDir.Value, $"{GetCacheKey(request, summary)}.response.txt"); - - private static string GetSolutionDirectory() - { - const string filename = "SmartComponents.sln"; - var dir = new DirectoryInfo(Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!); - while (dir != null) - { - if (dir.EnumerateFiles(filename).Any()) - { - return dir.FullName; - } - - dir = dir.Parent; - } - - throw new InvalidOperationException($"Could not find directory containing {filename}"); - } - - private static string GetCacheKeyInput(T request) - { - return JsonSerializer.Serialize(request).Replace("\\r", ""); - } - - private static string GetCacheKey(T request, string summary) - { - var json = GetCacheKeyInput(request); - var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(json)); - - var sb = new StringBuilder(); - for (var i = 0; i < 8; i++) - { - sb.Append(hash[i].ToString("x2")); - } - - sb.Append("_"); - sb.Append(ToShortSafeString(summary)); - - return sb.ToString(); - } - - private static string ToShortSafeString(string summary) - { - // This is just to make the cache filenames more recognizable. Won't help much if there's a common long prefix. - var sb = new StringBuilder(); - foreach (var c in summary) - { - if (char.IsLetterOrDigit(c)) - { - sb.Append(c); - } - else if (c == ' ') - { - sb.Append('_'); - } - - if (sb.Length >= 30) - { - break; - } - } - return sb.ToString(); - } -} -#endif diff --git a/src/SmartComponents.Inference.OpenAI/SelfHostedLlmTransport.cs b/src/SmartComponents.Inference.OpenAI/SelfHostedLlmTransport.cs deleted file mode 100644 index 9c79915..0000000 --- a/src/SmartComponents.Inference.OpenAI/SelfHostedLlmTransport.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Threading.Tasks; -using Azure.Core; -using Azure.Core.Pipeline; - -namespace SmartComponents.Inference.OpenAI; - -/// -/// Used to resolve queries using Ollama or anything else that exposes an OpenAI-compatible -/// endpoint with a scheme/host/port set of your choice. -/// -internal class SelfHostedLlmTransport(Uri endpoint) : HttpClientTransport -{ - public override ValueTask ProcessAsync(HttpMessage message) - { - message.Request.Uri.Scheme = endpoint.Scheme; - message.Request.Uri.Host = endpoint.Host; - message.Request.Uri.Port = endpoint.Port; - return base.ProcessAsync(message); - } -} diff --git a/src/SmartComponents.Inference.OpenAI/SmartComponents.Inference.OpenAI.csproj b/src/SmartComponents.Inference.OpenAI/SmartComponents.Inference.OpenAI.csproj deleted file mode 100644 index ed231ad..0000000 --- a/src/SmartComponents.Inference.OpenAI/SmartComponents.Inference.OpenAI.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - netstandard2.0 - latest - true - enable - - - - - - - - - diff --git a/src/SmartComponents.Inference/IInferenceBackend.cs b/src/SmartComponents.Inference/IInferenceBackend.cs deleted file mode 100644 index 0ed0771..0000000 --- a/src/SmartComponents.Inference/IInferenceBackend.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Threading.Tasks; - -namespace SmartComponents.StaticAssets.Inference; - -public interface IInferenceBackend -{ - Task GetChatResponseAsync(ChatParameters options); -}