diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs b/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs index 5857bb8526..41fa86c67c 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs @@ -1,17 +1,34 @@ // 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); + .WithEnvironment("OpenAI__DeploymentName", "modelA1") // Used by the Pages/Home.razor component + .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..0ea1b00ee4 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs @@ -1,13 +1,21 @@ // 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 +23,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 288e01adb4..444d9f4870 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