From c565c537f42d4184550c07aacb1284b3a7962155 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 10 Oct 2024 09:27:33 +0100 Subject: [PATCH 1/8] Add AddAzureOpenAIChatClient --- Directory.Packages.props | 2 + eng/Versions.props | 1 + .../Components/App.razor | 8 +- .../Components/Pages/Home.razor | 1 - .../Components/Pages/UseIChatClient.razor | 37 ++++++++ .../OpenAIEndToEnd.WebStory.csproj | 2 + .../OpenAIEndToEnd.WebStory/Program.cs | 2 +- .../Aspire.Azure.AI.OpenAI.csproj | 2 + .../AspireAzureOpenAIChatClientExtensions.cs | 84 +++++++++++++++++++ .../AspireAzureOpenAIExtensions.cs | 2 +- .../PublicAPI.Unshipped.txt | 2 + .../Aspire.OpenAI/Aspire.OpenAI.csproj | 5 +- .../MEAIPackageOverrides.targets | 13 +++ 13 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/UseIChatClient.razor create mode 100644 src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs create mode 100644 src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets diff --git a/Directory.Packages.props b/Directory.Packages.props index ddc8a6da1d..b32c27a940 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -92,6 +92,8 @@ + + diff --git a/eng/Versions.props b/eng/Versions.props index 43b8ac325b..7cffa7ed19 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -39,6 +39,7 @@ 9.0.0-beta.24516.2 9.0.0-beta.24516.2 9.0.0-beta.24516.2 + 9.0.0-preview.9.24507.7 9.0.0-preview.9.24518.1 8.10.0 8.0.0 diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/App.razor b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/App.razor index 0f9b81fecd..7ff7bd32e3 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/App.razor +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/App.razor @@ -7,12 +7,16 @@ - + - + + +@code { + IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/Home.razor b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/Home.razor index f989e760ce..543b9804ae 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/Home.razor +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/Home.razor @@ -1,5 +1,4 @@ @page "/" -@rendermode @(new InteractiveServerRenderMode(prerender: false)) @using OpenAI @using OpenAI.Chat @inject OpenAIClient aiClient diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/UseIChatClient.razor b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/UseIChatClient.razor new file mode 100644 index 0000000000..69e3e68db2 --- /dev/null +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Components/Pages/UseIChatClient.razor @@ -0,0 +1,37 @@ +@page "/useichatclient" +@using Microsoft.Extensions.AI +@inject IChatClient aiClient +@inject ILogger logger +@inject IConfiguration configuration + +
+ @foreach (var message in chatMessages.Where(m => m.Role == ChatRole.Assistant)) + { +

@message.Text

+ } + + +
+ +@code { + private List chatMessages = new List + { + new(ChatRole.System, "Pick a random topic and write a sentence of a fictional story about it.") + }; + + private async Task GenerateNextParagraph() + { + if (chatMessages.Count > 1) + { + chatMessages.Add(new (ChatRole.User, "Write the next sentence in the story.")); + } + + var response = await aiClient.CompleteAsync(chatMessages); + chatMessages.Add(response.Message); + } + + protected override async Task OnInitializedAsync() + { + await GenerateNextParagraph(); + } +} diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj index ede52a8521..e9ab77317f 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj @@ -11,4 +11,6 @@ + + diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs index 5b98bcffa3..3ca85cb813 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs @@ -7,7 +7,7 @@ builder.AddServiceDefaults(); -builder.AddAzureOpenAIClient("openai"); +builder.AddAzureOpenAIChatClient("openai"); // Add services to the container. builder.Services.AddRazorComponents() diff --git a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj index 9dc56c3b4e..4c75ad910a 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj +++ b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj @@ -34,4 +34,6 @@ + + diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs new file mode 100644 index 0000000000..b80c9962e2 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; +using Aspire.Azure.AI.OpenAI; +using Azure.AI.OpenAI; +using Azure.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenAI; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering as a singleton in the services provided by the . +/// +public static class AspireAzureOpenAIChatClientExtensions +{ + private const string DeployentKey = "Deployment"; + private const string ModelKey = "Model"; + + /// + /// Registers a singleton in the services provided by the . + /// + /// Additionally, registers the underlying and as singleton services. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the pipeline. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Optionally specifies the deployment name. If not specified, a value will be taken from the connection string. + /// Reads the configuration from "Aspire.Azure.AI.OpenAI" section. + public static void AddAzureOpenAIChatClient( + this IHostApplicationBuilder builder, + string connectionName, + Func? configurePipeline = null, + Action? configureSettings = null, + Action>? configureClientBuilder = null, + string? deploymentName = null) + { + builder.AddAzureOpenAIClient(connectionName, configureSettings, configureClientBuilder); + + builder.Services.AddSingleton(services => + { + var chatClientBuilder = new ChatClientBuilder(services); + configurePipeline?.Invoke(chatClientBuilder); + + deploymentName ??= GetRequiredDeploymentName(builder.Configuration, connectionName); + + var innerClient = chatClientBuilder.Services + .GetRequiredService() + .AsChatClient(deploymentName); + + return chatClientBuilder.Use(innerClient); + }); + } + + private static string GetRequiredDeploymentName(IConfiguration configuration, string connectionName) + { + string? deploymentName = null; + + if (configuration.GetConnectionString(connectionName) is string connectionString) + { + var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + deploymentName = (connectionBuilder[DeployentKey] ?? connectionBuilder[ModelKey]).ToString(); + } + + var configurationSectionName = AspireAzureOpenAIExtensions.DefaultConfigSectionName; + if (string.IsNullOrEmpty(deploymentName)) + { + var configSection = configuration.GetSection(configurationSectionName); + deploymentName = configSection[DeployentKey]; + } + + if (string.IsNullOrEmpty(deploymentName)) + { + throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a '{DeployentKey}' or '{ModelKey}' value is provided in 'ConnectionStrings:{connectionName}', or specify a '{DeployentKey}' in the '{configurationSectionName}' configuration section, or specify a '{nameof(deploymentName)}' in the call to {nameof(AddAzureOpenAIChatClient)}."); + } + + return deploymentName; + } +} diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs index 32e05e062a..35ddf55ef8 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs @@ -22,7 +22,7 @@ namespace Microsoft.Extensions.Hosting; /// public static class AspireAzureOpenAIExtensions { - private const string DefaultConfigSectionName = "Aspire:Azure:AI:OpenAI"; + internal const string DefaultConfigSectionName = "Aspire:Azure:AI:OpenAI"; /// /// Registers as a singleton in the services provided by the . diff --git a/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt b/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt index 1f2eb92b6e..a956c0122a 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt +++ b/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt @@ -11,8 +11,10 @@ Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Endpoint.get -> System.Uri? Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Endpoint.set -> void Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Key.get -> string? Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Key.set -> void +Microsoft.Extensions.Hosting.AspireAzureOpenAIChatClientExtensions Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions +static Microsoft.Extensions.Hosting.AspireAzureOpenAIChatClientExtensions.AddAzureOpenAIChatClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Func? configurePipeline = null, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null, string? deploymentName = null) -> void static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> void static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddKeyedAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> void static Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions.AddKeyedOpenAIClientFromConfiguration(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name) -> void diff --git a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj index fd8747d8ac..228fa521d3 100644 --- a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj +++ b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj @@ -19,9 +19,12 @@ - + + + + diff --git a/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets b/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets new file mode 100644 index 0000000000..268bffe277 --- /dev/null +++ b/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets @@ -0,0 +1,13 @@ + + + + + + + + + From 0c85b8fd51f4411cec5285185bc86290ba80dcc2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 25 Oct 2024 15:14:56 +0100 Subject: [PATCH 2/8] Set exact dependency versions in MEAI --- src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets b/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets index 268bffe277..c225119ef2 100644 --- a/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets +++ b/src/Components/Aspire.OpenAI/MEAIPackageOverrides.targets @@ -5,9 +5,9 @@ to avoid "package downgrade" build errors. This is only used when referencing Aspire.OpenAI and doesn't break compatibility with net8.0. --> - - - - + + + + From 7ba67f3f5bfc00f405579f58b7a22abb26a998f9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 25 Oct 2024 15:16:23 +0100 Subject: [PATCH 3/8] Fix typo --- .../AspireAzureOpenAIChatClientExtensions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs index b80c9962e2..e02d910308 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs @@ -17,7 +17,7 @@ namespace Microsoft.Extensions.Hosting; /// public static class AspireAzureOpenAIChatClientExtensions { - private const string DeployentKey = "Deployment"; + private const string DeploymentKey = "Deployment"; private const string ModelKey = "Model"; /// @@ -64,19 +64,19 @@ private static string GetRequiredDeploymentName(IConfiguration configuration, st if (configuration.GetConnectionString(connectionName) is string connectionString) { var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; - deploymentName = (connectionBuilder[DeployentKey] ?? connectionBuilder[ModelKey]).ToString(); + deploymentName = (connectionBuilder[DeploymentKey] ?? connectionBuilder[ModelKey]).ToString(); } var configurationSectionName = AspireAzureOpenAIExtensions.DefaultConfigSectionName; if (string.IsNullOrEmpty(deploymentName)) { var configSection = configuration.GetSection(configurationSectionName); - deploymentName = configSection[DeployentKey]; + deploymentName = configSection[DeploymentKey]; } if (string.IsNullOrEmpty(deploymentName)) { - throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a '{DeployentKey}' or '{ModelKey}' value is provided in 'ConnectionStrings:{connectionName}', or specify a '{DeployentKey}' in the '{configurationSectionName}' configuration section, or specify a '{nameof(deploymentName)}' in the call to {nameof(AddAzureOpenAIChatClient)}."); + throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a '{DeploymentKey}' or '{ModelKey}' value is provided in 'ConnectionStrings:{connectionName}', or specify a '{DeploymentKey}' in the '{configurationSectionName}' configuration section, or specify a '{nameof(deploymentName)}' in the call to {nameof(AddAzureOpenAIChatClient)}."); } return deploymentName; From 7db67719d579ceaf823c1bc39c4433c8ce4d59c3 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 25 Oct 2024 15:20:11 +0100 Subject: [PATCH 4/8] Handle connection string missing values --- .../AspireAzureOpenAIChatClientExtensions.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs index e02d910308..2698a58c5d 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs @@ -64,7 +64,8 @@ private static string GetRequiredDeploymentName(IConfiguration configuration, st if (configuration.GetConnectionString(connectionName) is string connectionString) { var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; - deploymentName = (connectionBuilder[DeploymentKey] ?? connectionBuilder[ModelKey]).ToString(); + deploymentName = ConnectionStringValue(connectionBuilder, DeploymentKey) + ?? ConnectionStringValue(connectionBuilder, ModelKey); } var configurationSectionName = AspireAzureOpenAIExtensions.DefaultConfigSectionName; @@ -81,4 +82,7 @@ private static string GetRequiredDeploymentName(IConfiguration configuration, st return deploymentName; } + + private static string? ConnectionStringValue(DbConnectionStringBuilder connectionString, string key) + => connectionString.TryGetValue(key, out var value) ? value as string : null; } From 692049632bf242ac6497cce557a67397f938f1d8 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 25 Oct 2024 15:23:23 +0100 Subject: [PATCH 5/8] Handle connection string including duplicate values --- .../AspireAzureOpenAIChatClientExtensions.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs index 2698a58c5d..a624c9a894 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs @@ -64,8 +64,15 @@ private static string GetRequiredDeploymentName(IConfiguration configuration, st if (configuration.GetConnectionString(connectionName) is string connectionString) { var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; - deploymentName = ConnectionStringValue(connectionBuilder, DeploymentKey) - ?? ConnectionStringValue(connectionBuilder, ModelKey); + var deploymentValue = ConnectionStringValue(connectionBuilder, DeploymentKey); + var modelValue = ConnectionStringValue(connectionBuilder, ModelKey); + if (deploymentValue is not null && modelValue is not null) + { + throw new InvalidOperationException( + $"The connection string '{connectionName}' contains both '{DeploymentKey}' and '{ModelKey}' keys. Either of these may be specified, but not both."); + } + + deploymentName = deploymentValue ?? modelValue; } var configurationSectionName = AspireAzureOpenAIExtensions.DefaultConfigSectionName; From 9b7150e7a2d05aa7bf065cf2d939f157e5c7c22c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 25 Oct 2024 16:10:46 +0100 Subject: [PATCH 6/8] Change to chaining API --- .../OpenAIEndToEnd.WebStory/Program.cs | 2 +- .../AspireAzureOpenAIClientBuilder.cs | 40 ++++++++++ ...penAIClientBuilderChatClientExtensions.cs} | 74 +++++++++++-------- .../AspireAzureOpenAIExtensions.cs | 8 +- .../PublicAPI.Unshipped.txt | 14 +++- 5 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilder.cs rename src/Components/Aspire.Azure.AI.OpenAI/{AspireAzureOpenAIChatClientExtensions.cs => AspireAzureOpenAIClientBuilderChatClientExtensions.cs} (52%) diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs index 3ca85cb813..d8fcb174c3 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs @@ -7,7 +7,7 @@ builder.AddServiceDefaults(); -builder.AddAzureOpenAIChatClient("openai"); +builder.AddAzureOpenAIClient("openai").AddChatClient(); // Add services to the container. builder.Services.AddRazorComponents() diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilder.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilder.cs new file mode 100644 index 0000000000..f232341f70 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilder.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.AI.OpenAI; + +namespace Microsoft.Extensions.Hosting; + +/// +/// A builder for configuring an service registration. +/// +public class AspireAzureOpenAIClientBuilder +{ + /// + /// Constructs a new instance of . + /// + /// The with which services are being registered. + /// The name used to retrieve the connection string from the ConnectionStrings configuration section. + /// The service key used to register the service, if any. + public AspireAzureOpenAIClientBuilder(IHostApplicationBuilder hostBuilder, string connectionName, string? serviceKey) + { + HostBuilder = hostBuilder; + ConnectionName = connectionName; + ServiceKey = serviceKey; + } + + /// + /// Gets the with which services are being registered. + /// + public IHostApplicationBuilder HostBuilder { get; } + + /// + /// Gets the name used to retrieve the connection string from the ConnectionStrings configuration section. + /// + public string ConnectionName { get; } + + /// + /// Gets the service key used to register the service, if any. + /// + public string? ServiceKey { get; } +} diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs similarity index 52% rename from src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs rename to src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs index a624c9a894..c066061f08 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIChatClientExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs @@ -2,59 +2,73 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data.Common; -using Aspire.Azure.AI.OpenAI; using Azure.AI.OpenAI; -using Azure.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using OpenAI; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.Hosting; /// /// Provides extension methods for registering as a singleton in the services provided by the . /// -public static class AspireAzureOpenAIChatClientExtensions +public static class AspireAzureOpenAIClientBuilderChatClientExtensions { private const string DeploymentKey = "Deployment"; private const string ModelKey = "Model"; /// /// Registers a singleton in the services provided by the . - /// - /// Additionally, registers the underlying and as singleton services. /// - /// The to read config from and add services to. - /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An . + /// Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string. /// An optional method that can be used for customizing the pipeline. - /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. - /// An optional method that can be used for customizing the . - /// Optionally specifies the deployment name. If not specified, a value will be taken from the connection string. /// Reads the configuration from "Aspire.Azure.AI.OpenAI" section. - public static void AddAzureOpenAIChatClient( - this IHostApplicationBuilder builder, - string connectionName, - Func? configurePipeline = null, - Action? configureSettings = null, - Action>? configureClientBuilder = null, - string? deploymentName = null) + public static void AddChatClient( + this AspireAzureOpenAIClientBuilder builder, + string? deploymentName = null, + Func? configurePipeline = null) { - builder.AddAzureOpenAIClient(connectionName, configureSettings, configureClientBuilder); + builder.HostBuilder.Services.AddSingleton( + services => CreateChatClient(services, builder, deploymentName, configurePipeline)); + } - builder.Services.AddSingleton(services => - { - var chatClientBuilder = new ChatClientBuilder(services); - configurePipeline?.Invoke(chatClientBuilder); + /// + /// Registers a keyed singleton in the services provided by the . + /// + /// An . + /// The service key with which the will be registered. + /// Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string. + /// An optional method that can be used for customizing the pipeline. + /// Reads the configuration from "Aspire.Azure.AI.OpenAI" section. + public static void AddKeyedChatClient( + this AspireAzureOpenAIClientBuilder builder, + string serviceKey, + string? deploymentName = null, + Func? configurePipeline = null) + { + builder.HostBuilder.Services.TryAddKeyedSingleton( + serviceKey, + (services, _) => CreateChatClient(services, builder, deploymentName, configurePipeline)); + } + + private static IChatClient CreateChatClient( + IServiceProvider services, + AspireAzureOpenAIClientBuilder builder, + string? deploymentName, + Func? configurePipeline) + { + var openAiClient = builder.ServiceKey is null + ? services.GetRequiredService() + : services.GetRequiredKeyedService(builder.ServiceKey); - deploymentName ??= GetRequiredDeploymentName(builder.Configuration, connectionName); + var chatClientBuilder = new ChatClientBuilder(services); + configurePipeline?.Invoke(chatClientBuilder); - var innerClient = chatClientBuilder.Services - .GetRequiredService() - .AsChatClient(deploymentName); + deploymentName ??= GetRequiredDeploymentName(builder.HostBuilder.Configuration, builder.ConnectionName); - return chatClientBuilder.Use(innerClient); - }); + return chatClientBuilder.Use(openAiClient.AsChatClient(deploymentName)); } private static string GetRequiredDeploymentName(IConfiguration configuration, string connectionName) @@ -84,7 +98,7 @@ private static string GetRequiredDeploymentName(IConfiguration configuration, st if (string.IsNullOrEmpty(deploymentName)) { - throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a '{DeploymentKey}' or '{ModelKey}' value is provided in 'ConnectionStrings:{connectionName}', or specify a '{DeploymentKey}' in the '{configurationSectionName}' configuration section, or specify a '{nameof(deploymentName)}' in the call to {nameof(AddAzureOpenAIChatClient)}."); + throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a '{DeploymentKey}' or '{ModelKey}' value is provided in 'ConnectionStrings:{connectionName}', or specify a '{DeploymentKey}' in the '{configurationSectionName}' configuration section, or specify a '{nameof(deploymentName)}' in the call to {nameof(AddChatClient)}."); } return deploymentName; diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs index 35ddf55ef8..d152655026 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIExtensions.cs @@ -34,7 +34,7 @@ public static class AspireAzureOpenAIExtensions /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . /// Reads the configuration from "Aspire.Azure.AI.OpenAI" section. - public static void AddAzureOpenAIClient( + public static AspireAzureOpenAIClientBuilder AddAzureOpenAIClient( this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null, @@ -44,6 +44,8 @@ public static void AddAzureOpenAIClient( // Add the AzureOpenAIClient service as OpenAIClient. That way the service can be resolved by both service Types. builder.Services.TryAddSingleton(typeof(OpenAIClient), static provider => provider.GetRequiredService()); + + return new AspireAzureOpenAIClientBuilder(builder, connectionName, serviceKey: null); } /// @@ -56,7 +58,7 @@ public static void AddAzureOpenAIClient( /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . /// Reads the configuration from "Aspire.Azure.AI.OpenAI:{name}" section. - public static void AddKeyedAzureOpenAIClient( + public static AspireAzureOpenAIClientBuilder AddKeyedAzureOpenAIClient( this IHostApplicationBuilder builder, string name, Action? configureSettings = null, @@ -68,6 +70,8 @@ public static void AddKeyedAzureOpenAIClient( // Add the AzureOpenAIClient service as OpenAIClient. That way the service can be resolved by both service Types. builder.Services.TryAddKeyedSingleton(typeof(OpenAIClient), serviceKey: name, static (provider, key) => provider.GetRequiredKeyedService(key)); + + return new AspireAzureOpenAIClientBuilder(builder, name, name); } private sealed class OpenAIComponent : AzureComponent diff --git a/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt b/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt index a956c0122a..3d05c2f8c9 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt +++ b/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt @@ -11,11 +11,17 @@ Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Endpoint.get -> System.Uri? Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Endpoint.set -> void Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Key.get -> string? Aspire.Azure.AI.OpenAI.AzureOpenAISettings.Key.set -> void -Microsoft.Extensions.Hosting.AspireAzureOpenAIChatClientExtensions +Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder +Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder.AspireAzureOpenAIClientBuilder(Microsoft.Extensions.Hosting.IHostApplicationBuilder! hostBuilder, string! connectionName, string? serviceKey) -> void +Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder.ConnectionName.get -> string! +Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder.HostBuilder.get -> Microsoft.Extensions.Hosting.IHostApplicationBuilder! +Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder.ServiceKey.get -> string? +Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions -static Microsoft.Extensions.Hosting.AspireAzureOpenAIChatClientExtensions.AddAzureOpenAIChatClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Func? configurePipeline = null, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null, string? deploymentName = null) -> void -static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> void -static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddKeyedAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> void +static Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions.AddChatClient(this Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! builder, string? deploymentName = null, System.Func? configurePipeline = null) -> void +static Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions.AddKeyedChatClient(this Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! builder, string! serviceKey, string? deploymentName = null, System.Func? configurePipeline = null) -> void +static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! +static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddKeyedAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! static Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions.AddKeyedOpenAIClientFromConfiguration(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name) -> void static Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions.AddOpenAIClientFromConfiguration(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName) -> void From 16a16a328b9c97f8108e9bcd615b74d74b31c8cd Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 13 Nov 2024 16:17:17 -0800 Subject: [PATCH 7/8] Configure OpenAI models in the app host --- .../OpenAIEndToEnd.AppHost/Program.cs | 28 ++++++-- .../OpenAIEndToEnd.WebStory/Program.cs | 18 ++++- .../AzureOpenAIExtensions.cs | 36 +++++++++- .../AzureOpenAIResource.cs | 9 ++- .../PublicAPI.Unshipped.txt | 2 + .../ResourceBuilderExtensions.cs | 6 +- ...OpenAIClientBuilderChatClientExtensions.cs | 66 ++++++++----------- .../DeploymentModelSettings.cs | 19 ++++++ .../PublicAPI.Unshipped.txt | 4 +- 9 files changed, 135 insertions(+), 53 deletions(-) create mode 100644 src/Components/Aspire.Azure.AI.OpenAI/DeploymentModelSettings.cs diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs b/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs index 5857bb8526..447713d5de 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs @@ -1,17 +1,33 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure.Provisioning.CognitiveServices; + var builder = DistributedApplication.CreateBuilder(args); -var deploymentAndModelName = "gpt-4o"; -var openai = builder.AddAzureOpenAI("openai").AddDeployment( - new(deploymentAndModelName, deploymentAndModelName, "2024-05-13") - ); +var openaiA = builder.AddAzureOpenAI("openaiA") + .ConfigureInfrastructure(infra => + { + var cognitiveAccount = infra.GetProvisionableResources().OfType().Single(); + cognitiveAccount.Properties.DisableLocalAuth = false; + }) + .AddDeployment(new("modelA1", "gpt-4o", "2024-05-13")) + ; + +//var openaiB = builder.AddAzureOpenAI("openaiB") +// .ConfigureInfrastructure(infra => +// { +// var cognitiveAccount = infra.GetProvisionableResources().OfType().Single(); +// cognitiveAccount.Properties.DisableLocalAuth = false; +// }) +// .AddDeployment(new("modelB1", "gpt-4o", "2024-05-13")) +// .AddDeployment(new("modelB2", "gpt-4o", "2024-05-13")); builder.AddProject("webstory") .WithExternalHttpEndpoints() - .WithReference(openai) - .WithEnvironment("OpenAI__DeploymentName", deploymentAndModelName); + .WithReference(openaiA) + //.WithReference(openaiB) + ; #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs index d8fcb174c3..d1dce030d8 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs @@ -1,13 +1,22 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; using OpenAIEndToEnd.WebStory.Components; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); -builder.AddAzureOpenAIClient("openai").AddChatClient(); +builder.AddAzureOpenAIClient("openaiA") + .AddChatClient(); + +// Examples using multiple OpenAI resources and multiple models + +//builder.AddKeyedAzureOpenAIClient("openaiB") +// .AddKeyedChatClient("modelB1") +// .AddKeyedChatClient("modelB2"); // Add services to the container. builder.Services.AddRazorComponents() @@ -15,6 +24,13 @@ var app = builder.Build(); +ArgumentNullException.ThrowIfNull(app.Services.GetService()); +ArgumentNullException.ThrowIfNull(app.Services.GetService()); + +//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService("openaiB")); +//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService("modelB1")); +//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService("modelB2")); + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs index 27b98ffda8..d7f9780710 100644 --- a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs +++ b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs @@ -14,6 +14,8 @@ namespace Aspire.Hosting; /// public static class AzureOpenAIExtensions { + internal const string DefaultConfigSectionName = "Aspire:Azure:AI:OpenAI"; + /// /// Adds an Azure OpenAI resource to the application model. /// @@ -103,7 +105,7 @@ public static IResourceBuilder AddAzureOpenAI(this IDistrib } /// - /// Adds an Azure OpenAI Deployment resource to the application model. This resource requires an to be added to the application model. + /// Adds an Azure OpenAI Deployment to the resource. This resource requires an to be added to the application model. /// /// The Azure OpenAI resource builder. /// The deployment to add. @@ -111,6 +113,38 @@ public static IResourceBuilder AddAzureOpenAI(this IDistrib public static IResourceBuilder AddDeployment(this IResourceBuilder builder, AzureOpenAIDeployment deployment) { builder.Resource.AddDeployment(deployment); + return builder; } + + /// + /// Injects the environment variables from the source into the destination resource, using the source resource's name as the connection string name (if not overridden). + /// The format of the connection environment variable will be "ConnectionStrings__{sourceResourceName}={connectionString}". + /// Each deployment will be injected using the format "Aspire__Azure__AI__OpenAI__{sourceResourceName}__Models__{deploymentName}={modelName}". + /// + /// The destination resource. + /// The resource where connection string will be injected. + /// The resource from which to extract the connection string. + /// An override of the source resource's name for the connection string. The resulting connection string will be "ConnectionStrings__connectionName" if this is not null. + /// A reference to the . + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder source, string? resourceName = null) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + var resource = source.Resource; + resourceName ??= resource.Name; + + builder.WithReference((IResourceBuilder)source, resourceName); + + return builder.WithEnvironment(context => + { + foreach (var deployment in resource.Deployments) + { + var variableName = $"ASPIRE__AZURE__AI__OPENAI__{resourceName}__MODELS__{deployment.Name}"; + context.EnvironmentVariables[variableName] = deployment.ModelName; + } + }); + } } diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs index c7dcb13441..55ae41e698 100644 --- a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs +++ b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs @@ -11,7 +11,8 @@ namespace Aspire.Hosting.ApplicationModel; /// Configures the underlying Azure resource using the CDK. public class AzureOpenAIResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure), - IResourceWithConnectionString + IResourceWithConnectionString, + IResourceWithEnvironment { private readonly List _deployments = []; @@ -31,7 +32,11 @@ public class AzureOpenAIResource(string name, Action public IReadOnlyList Deployments => _deployments; - internal void AddDeployment(AzureOpenAIDeployment deployment) + /// + /// Adds an instance to the list of deployments. + /// + /// The instance to add. + public void AddDeployment(AzureOpenAIDeployment deployment) { ArgumentNullException.ThrowIfNull(deployment); diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.CognitiveServices/PublicAPI.Unshipped.txt index 62a2775042..5bed4aa623 100644 --- a/src/Aspire.Hosting.Azure.CognitiveServices/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure.CognitiveServices/PublicAPI.Unshipped.txt @@ -5,4 +5,6 @@ Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment.AzureOpenAIDeployment(string! name, string! modelName, string! modelVersion, string? skuName = null, int? skuCapacity = null) -> void Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment.SkuCapacity.set -> void Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment.SkuName.set -> void +Aspire.Hosting.ApplicationModel.AzureOpenAIResource.AddDeployment(Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment! deployment) -> void Aspire.Hosting.ApplicationModel.AzureOpenAIResource.AzureOpenAIResource(string! name, System.Action! configureInfrastructure) -> void +static Aspire.Hosting.AzureOpenAIExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! source, string? resourceName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 4c6e44572f..0fd6002946 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -323,7 +323,7 @@ private static Action CreateEndpointReferenceEnviron /// /// Injects a connection string as an environment variable from the source resource into the destination resource, using the source resource's name as the connection string name (if not overridden). - /// The format of the environment variable will be "ConnectionStrings__{sourceResourceName}={connectionString}." + /// The format of the environment variable will be "ConnectionStrings__{sourceResourceName}={connectionString}". /// /// Each resource defines the format of the connection string value. The /// underlying connection string value can be retrieved using . @@ -359,7 +359,7 @@ public static IResourceBuilder WithReference(this IR /// /// Injects service discovery information as environment variables from the project resource into the destination resource, using the source resource's name as the service name. - /// Each endpoint defined on the project resource will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}." + /// Each endpoint defined on the project resource will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}". /// /// The destination resource. /// The resource where the service discovery information will be injected. @@ -406,7 +406,7 @@ public static IResourceBuilder WithReference(this IR /// /// Injects service discovery information from the specified endpoint into the project resource using the source resource's name as the service name. - /// Each endpoint will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}." + /// Each endpoint will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}". /// /// The destination resource. /// The resource where the service discovery information will be injected. diff --git a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs index c066061f08..6cebea70c7 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs +++ b/src/Components/Aspire.Azure.AI.OpenAI/AspireAzureOpenAIClientBuilderChatClientExtensions.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Data.Common; +using Aspire.Azure.AI.OpenAI; using Azure.AI.OpenAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -15,9 +15,6 @@ namespace Microsoft.Extensions.Hosting; /// public static class AspireAzureOpenAIClientBuilderChatClientExtensions { - private const string DeploymentKey = "Deployment"; - private const string ModelKey = "Model"; - /// /// Registers a singleton in the services provided by the . /// @@ -25,13 +22,15 @@ public static class AspireAzureOpenAIClientBuilderChatClientExtensions /// Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string. /// An optional method that can be used for customizing the pipeline. /// Reads the configuration from "Aspire.Azure.AI.OpenAI" section. - public static void AddChatClient( + public static AspireAzureOpenAIClientBuilder AddChatClient( this AspireAzureOpenAIClientBuilder builder, string? deploymentName = null, Func? configurePipeline = null) { builder.HostBuilder.Services.AddSingleton( services => CreateChatClient(services, builder, deploymentName, configurePipeline)); + + return builder; } /// @@ -39,18 +38,18 @@ public static void AddChatClient( /// /// An . /// The service key with which the will be registered. - /// Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string. /// An optional method that can be used for customizing the pipeline. /// Reads the configuration from "Aspire.Azure.AI.OpenAI" section. - public static void AddKeyedChatClient( + public static AspireAzureOpenAIClientBuilder AddKeyedChatClient( this AspireAzureOpenAIClientBuilder builder, string serviceKey, - string? deploymentName = null, Func? configurePipeline = null) { builder.HostBuilder.Services.TryAddKeyedSingleton( serviceKey, - (services, _) => CreateChatClient(services, builder, deploymentName, configurePipeline)); + (services, _) => CreateChatClient(services, builder, serviceKey, configurePipeline)); + + return builder; } private static IChatClient CreateChatClient( @@ -66,44 +65,35 @@ private static IChatClient CreateChatClient( var chatClientBuilder = new ChatClientBuilder(services); configurePipeline?.Invoke(chatClientBuilder); - deploymentName ??= GetRequiredDeploymentName(builder.HostBuilder.Configuration, builder.ConnectionName); - - return chatClientBuilder.Use(openAiClient.AsChatClient(deploymentName)); - } - - private static string GetRequiredDeploymentName(IConfiguration configuration, string connectionName) - { - string? deploymentName = null; + var deploymentSettings = GetDeployments(builder.HostBuilder.Configuration, builder.ConnectionName); - if (configuration.GetConnectionString(connectionName) is string connectionString) + // If no deployment name is provided, we search for the first one (and maybe only one) in configuration + if (deploymentName is null) { - var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; - var deploymentValue = ConnectionStringValue(connectionBuilder, DeploymentKey); - var modelValue = ConnectionStringValue(connectionBuilder, ModelKey); - if (deploymentValue is not null && modelValue is not null) + deploymentName = deploymentSettings.Models.Keys.FirstOrDefault(); + + if (string.IsNullOrEmpty(deploymentName)) { - throw new InvalidOperationException( - $"The connection string '{connectionName}' contains both '{DeploymentKey}' and '{ModelKey}' keys. Either of these may be specified, but not both."); + throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a deployment was defined ."); } - - deploymentName = deploymentValue ?? modelValue; - } - - var configurationSectionName = AspireAzureOpenAIExtensions.DefaultConfigSectionName; - if (string.IsNullOrEmpty(deploymentName)) - { - var configSection = configuration.GetSection(configurationSectionName); - deploymentName = configSection[DeploymentKey]; } - if (string.IsNullOrEmpty(deploymentName)) + if (!deploymentSettings.Models.TryGetValue(deploymentName, out var _)) { - throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a '{DeploymentKey}' or '{ModelKey}' value is provided in 'ConnectionStrings:{connectionName}', or specify a '{DeploymentKey}' in the '{configurationSectionName}' configuration section, or specify a '{nameof(deploymentName)}' in the call to {nameof(AddChatClient)}."); + throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure the deployment name '{deploymentName}' was defined ."); } - return deploymentName; + return chatClientBuilder.Use(openAiClient.AsChatClient(deploymentName)); } - private static string? ConnectionStringValue(DbConnectionStringBuilder connectionString, string key) - => connectionString.TryGetValue(key, out var value) ? value as string : null; + private static DeploymentModelSettings GetDeployments(IConfiguration configuration, string connectionName) + { + var configurationSectionName = $"{AspireAzureOpenAIExtensions.DefaultConfigSectionName}:{connectionName}"; + var configSection = configuration.GetSection(configurationSectionName); + + var settings = new DeploymentModelSettings(); + configSection.Bind(settings); + + return settings; + } } diff --git a/src/Components/Aspire.Azure.AI.OpenAI/DeploymentModelSettings.cs b/src/Components/Aspire.Azure.AI.OpenAI/DeploymentModelSettings.cs new file mode 100644 index 0000000000..826af81aa0 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.OpenAI/DeploymentModelSettings.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Azure.AI.OpenAI; + +/// +/// Helper class to bind the deployment models from configuration (deployment names and model names). +/// More specifically, it binds the "Aspire:Azure:AI:OpenAI:{resourceName}:Models" section. +/// +internal sealed class DeploymentModelSettings +{ + /// + /// Gets or sets the dictionary of deployment names and model names. + /// + /// + /// For instance { ["chat"] = "gpt-4o" }. + /// + public Dictionary Models { get; set; } = []; +} diff --git a/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt b/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt index 3d05c2f8c9..11e06c71af 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt +++ b/src/Components/Aspire.Azure.AI.OpenAI/PublicAPI.Unshipped.txt @@ -19,8 +19,8 @@ Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder.ServiceKey.get -> st Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions -static Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions.AddChatClient(this Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! builder, string? deploymentName = null, System.Func? configurePipeline = null) -> void -static Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions.AddKeyedChatClient(this Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! builder, string! serviceKey, string? deploymentName = null, System.Func? configurePipeline = null) -> void +static Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions.AddChatClient(this Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! builder, string? deploymentName = null, System.Func? configurePipeline = null) -> Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! +static Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilderChatClientExtensions.AddKeyedChatClient(this Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! builder, string! serviceKey, System.Func? configurePipeline = null) -> Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! static Microsoft.Extensions.Hosting.AspireAzureOpenAIExtensions.AddKeyedAzureOpenAIClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null, System.Action!>? configureClientBuilder = null) -> Microsoft.Extensions.Hosting.AspireAzureOpenAIClientBuilder! static Microsoft.Extensions.Hosting.AspireConfigurableOpenAIExtensions.AddKeyedOpenAIClientFromConfiguration(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name) -> void From 30eee29139299b9309a60139d5b3756e30a5fe90 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 13 Nov 2024 16:35:26 -0800 Subject: [PATCH 8/8] Restore custom configuration --- playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs b/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs index 447713d5de..41fa86c67c 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs @@ -25,6 +25,7 @@ builder.AddProject("webstory") .WithExternalHttpEndpoints() + .WithEnvironment("OpenAI__DeploymentName", "modelA1") // Used by the Pages/Home.razor component .WithReference(openaiA) //.WithReference(openaiB) ;