Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Configure OpenAI models in the app host #6577

Open
wants to merge 10 commits into
base: stevesa/meai-integration
Choose a base branch
from
29 changes: 23 additions & 6 deletions playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -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<CognitiveServicesAccount>().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<CognitiveServicesAccount>().Single();
// cognitiveAccount.Properties.DisableLocalAuth = false;
// })
// .AddDeployment(new("modelB1", "gpt-4o", "2024-05-13"))
// .AddDeployment(new("modelB2", "gpt-4o", "2024-05-13"));

builder.AddProject<Projects.OpenAIEndToEnd_WebStory>("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
Expand Down
17 changes: 16 additions & 1 deletion playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
// 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()
.AddInteractiveServerComponents();

var app = builder.Build();

ArgumentNullException.ThrowIfNull(app.Services.GetService<AzureOpenAIClient>());
ArgumentNullException.ThrowIfNull(app.Services.GetService<IChatClient>());

//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService<AzureOpenAIClient>("openaiB"));
//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService<IChatClient>("modelB1"));
//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService<IChatClient>("modelB2"));

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace Aspire.Hosting;
/// </summary>
public static class AzureOpenAIExtensions
{
internal const string DefaultConfigSectionName = "Aspire:Azure:AI:OpenAI";

/// <summary>
/// Adds an Azure OpenAI resource to the application model.
/// </summary>
Expand Down Expand Up @@ -103,14 +105,46 @@ public static IResourceBuilder<AzureOpenAIResource> AddAzureOpenAI(this IDistrib
}

/// <summary>
/// Adds an Azure OpenAI Deployment resource to the application model. This resource requires an <see cref="AzureOpenAIResource"/> to be added to the application model.
/// Adds an Azure OpenAI Deployment to the <see cref="AzureOpenAIResource"/> resource. This resource requires an <see cref="AzureOpenAIResource"/> to be added to the application model.
/// </summary>
/// <param name="builder">The Azure OpenAI resource builder.</param>
/// <param name="deployment">The deployment to add.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<AzureOpenAIResource> AddDeployment(this IResourceBuilder<AzureOpenAIResource> builder, AzureOpenAIDeployment deployment)
{
builder.Resource.AddDeployment(deployment);

return builder;
}

/// <summary>
sebastienros marked this conversation as resolved.
Show resolved Hide resolved
/// Injects the environment variables from the source <see cref="AzureOpenAIResource" /> 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}".
/// </summary>
/// <typeparam name="TDestination">The destination resource.</typeparam>
/// <param name="builder">The resource where connection string will be injected.</param>
/// <param name="source">The resource from which to extract the connection string.</param>
/// <param name="resourceName">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.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<TDestination> WithReference<TDestination>(this IResourceBuilder<TDestination> builder, IResourceBuilder<AzureOpenAIResource> source, string? resourceName = null)
where TDestination : IResourceWithEnvironment
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(source);

var resource = source.Resource;
resourceName ??= resource.Name;

builder.WithReference((IResourceBuilder<IResourceWithConnectionString>)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;
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ namespace Aspire.Hosting.ApplicationModel;
/// <param name="configureInfrastructure">Configures the underlying Azure resource using the CDK.</param>
public class AzureOpenAIResource(string name, Action<AzureResourceInfrastructure> configureInfrastructure) :
AzureProvisioningResource(name, configureInfrastructure),
IResourceWithConnectionString
IResourceWithConnectionString,
IResourceWithEnvironment
{
private readonly List<AzureOpenAIDeployment> _deployments = [];

Expand All @@ -31,7 +32,11 @@ public class AzureOpenAIResource(string name, Action<AzureResourceInfrastructure
/// </summary>
public IReadOnlyList<AzureOpenAIDeployment> Deployments => _deployments;

internal void AddDeployment(AzureOpenAIDeployment deployment)
/// <summary>
/// Adds an <see cref="AzureOpenAIDeployment"/> instance to the list of deployments.
/// </summary>
/// <param name="deployment">The <see cref="AzureOpenAIDeployment"/> instance to add.</param>
public void AddDeployment(AzureOpenAIDeployment deployment)
{
ArgumentNullException.ThrowIfNull(deployment);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Aspire.Hosting.Azure.AzureResourceInfrastructure!>! configureInfrastructure) -> void
static Aspire.Hosting.AzureOpenAIExtensions.WithReference<TDestination>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<TDestination>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.AzureOpenAIResource!>! source, string? resourceName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<TDestination>!
6 changes: 3 additions & 3 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ private static Action<EnvironmentCallbackContext> CreateEndpointReferenceEnviron

/// <summary>
/// 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}".
/// <para>
/// Each resource defines the format of the connection string value. The
/// underlying connection string value can be retrieved using <see cref="IResourceWithConnectionString.GetConnectionStringAsync(CancellationToken)"/>.
Expand Down Expand Up @@ -359,7 +359,7 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR

/// <summary>
/// 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}".
/// </summary>
/// <typeparam name="TDestination">The destination resource.</typeparam>
/// <param name="builder">The resource where the service discovery information will be injected.</param>
Expand Down Expand Up @@ -406,7 +406,7 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR

/// <summary>
/// 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}".
/// </summary>
/// <typeparam name="TDestination">The destination resource.</typeparam>
/// <param name="builder">The resource where the service discovery information will be injected.</param>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,42 +15,41 @@ namespace Microsoft.Extensions.Hosting;
/// </summary>
public static class AspireAzureOpenAIClientBuilderChatClientExtensions
{
private const string DeploymentKey = "Deployment";
private const string ModelKey = "Model";

/// <summary>
/// Registers a singleton <see cref="IChatClient"/> in the services provided by the <paramref name="builder"/>.
/// </summary>
/// <param name="builder">An <see cref="AspireAzureOpenAIClientBuilder" />.</param>
/// <param name="deploymentName">Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string.</param>
/// <param name="configurePipeline">An optional method that can be used for customizing the <see cref="IChatClient"/> pipeline.</param>
/// <remarks>Reads the configuration from "Aspire.Azure.AI.OpenAI" section.</remarks>
public static void AddChatClient(
public static AspireAzureOpenAIClientBuilder AddChatClient(
this AspireAzureOpenAIClientBuilder builder,
string? deploymentName = null,
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null)
{
builder.HostBuilder.Services.AddSingleton(
services => CreateChatClient(services, builder, deploymentName, configurePipeline));

return builder;
}

/// <summary>
/// Registers a keyed singleton <see cref="IChatClient"/> in the services provided by the <paramref name="builder"/>.
/// </summary>
/// <param name="builder">An <see cref="AspireAzureOpenAIClientBuilder" />.</param>
/// <param name="serviceKey">The service key with which the <see cref="IChatClient"/> will be registered.</param>
/// <param name="deploymentName">Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string.</param>
/// <param name="configurePipeline">An optional method that can be used for customizing the <see cref="IChatClient"/> pipeline.</param>
/// <remarks>Reads the configuration from "Aspire.Azure.AI.OpenAI" section.</remarks>
public static void AddKeyedChatClient(
public static AspireAzureOpenAIClientBuilder AddKeyedChatClient(
this AspireAzureOpenAIClientBuilder builder,
string serviceKey,
string? deploymentName = null,
Func<ChatClientBuilder, ChatClientBuilder>? 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(
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal sealed class DeploymentModelSettings
sebastienros marked this conversation as resolved.
Show resolved Hide resolved
{
/// <summary>
/// Gets or sets the dictionary of deployment names and model names.
/// </summary>
/// <remarks>
/// For instance <code>{ ["chat"] = "gpt-4o" }</code>.
/// </remarks>
public Dictionary<string, string> Models { get; set; } = [];
}
Loading