Skip to content

Commit 16a16a3

Browse files
committed
Configure OpenAI models in the app host
1 parent 4587b4b commit 16a16a3

File tree

9 files changed

+135
-53
lines changed

9 files changed

+135
-53
lines changed

playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/Program.cs

+22-6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Azure.Provisioning.CognitiveServices;
5+
46
var builder = DistributedApplication.CreateBuilder(args);
57

6-
var deploymentAndModelName = "gpt-4o";
7-
var openai = builder.AddAzureOpenAI("openai").AddDeployment(
8-
new(deploymentAndModelName, deploymentAndModelName, "2024-05-13")
9-
);
8+
var openaiA = builder.AddAzureOpenAI("openaiA")
9+
.ConfigureInfrastructure(infra =>
10+
{
11+
var cognitiveAccount = infra.GetProvisionableResources().OfType<CognitiveServicesAccount>().Single();
12+
cognitiveAccount.Properties.DisableLocalAuth = false;
13+
})
14+
.AddDeployment(new("modelA1", "gpt-4o", "2024-05-13"))
15+
;
16+
17+
//var openaiB = builder.AddAzureOpenAI("openaiB")
18+
// .ConfigureInfrastructure(infra =>
19+
// {
20+
// var cognitiveAccount = infra.GetProvisionableResources().OfType<CognitiveServicesAccount>().Single();
21+
// cognitiveAccount.Properties.DisableLocalAuth = false;
22+
// })
23+
// .AddDeployment(new("modelB1", "gpt-4o", "2024-05-13"))
24+
// .AddDeployment(new("modelB2", "gpt-4o", "2024-05-13"));
1025

1126
builder.AddProject<Projects.OpenAIEndToEnd_WebStory>("webstory")
1227
.WithExternalHttpEndpoints()
13-
.WithReference(openai)
14-
.WithEnvironment("OpenAI__DeploymentName", deploymentAndModelName);
28+
.WithReference(openaiA)
29+
//.WithReference(openaiB)
30+
;
1531

1632
#if !SKIP_DASHBOARD_REFERENCE
1733
// This project is only added in playground projects to support development/debugging

playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/Program.cs

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Azure.AI.OpenAI;
5+
using Microsoft.Extensions.AI;
46
using OpenAIEndToEnd.WebStory.Components;
57

68
var builder = WebApplication.CreateBuilder(args);
79

810
builder.AddServiceDefaults();
911

10-
builder.AddAzureOpenAIClient("openai").AddChatClient();
12+
builder.AddAzureOpenAIClient("openaiA")
13+
.AddChatClient();
14+
15+
// Examples using multiple OpenAI resources and multiple models
16+
17+
//builder.AddKeyedAzureOpenAIClient("openaiB")
18+
// .AddKeyedChatClient("modelB1")
19+
// .AddKeyedChatClient("modelB2");
1120

1221
// Add services to the container.
1322
builder.Services.AddRazorComponents()
1423
.AddInteractiveServerComponents();
1524

1625
var app = builder.Build();
1726

27+
ArgumentNullException.ThrowIfNull(app.Services.GetService<AzureOpenAIClient>());
28+
ArgumentNullException.ThrowIfNull(app.Services.GetService<IChatClient>());
29+
30+
//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService<AzureOpenAIClient>("openaiB"));
31+
//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService<IChatClient>("modelB1"));
32+
//ArgumentNullException.ThrowIfNull(app.Services.GetKeyedService<IChatClient>("modelB2"));
33+
1834
// Configure the HTTP request pipeline.
1935
if (!app.Environment.IsDevelopment())
2036
{

src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs

+35-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ namespace Aspire.Hosting;
1414
/// </summary>
1515
public static class AzureOpenAIExtensions
1616
{
17+
internal const string DefaultConfigSectionName = "Aspire:Azure:AI:OpenAI";
18+
1719
/// <summary>
1820
/// Adds an Azure OpenAI resource to the application model.
1921
/// </summary>
@@ -103,14 +105,46 @@ public static IResourceBuilder<AzureOpenAIResource> AddAzureOpenAI(this IDistrib
103105
}
104106

105107
/// <summary>
106-
/// Adds an Azure OpenAI Deployment resource to the application model. This resource requires an <see cref="AzureOpenAIResource"/> to be added to the application model.
108+
/// 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.
107109
/// </summary>
108110
/// <param name="builder">The Azure OpenAI resource builder.</param>
109111
/// <param name="deployment">The deployment to add.</param>
110112
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
111113
public static IResourceBuilder<AzureOpenAIResource> AddDeployment(this IResourceBuilder<AzureOpenAIResource> builder, AzureOpenAIDeployment deployment)
112114
{
113115
builder.Resource.AddDeployment(deployment);
116+
114117
return builder;
115118
}
119+
120+
/// <summary>
121+
/// 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).
122+
/// The format of the connection environment variable will be "ConnectionStrings__{sourceResourceName}={connectionString}".
123+
/// Each deployment will be injected using the format "Aspire__Azure__AI__OpenAI__{sourceResourceName}__Models__{deploymentName}={modelName}".
124+
/// </summary>
125+
/// <typeparam name="TDestination">The destination resource.</typeparam>
126+
/// <param name="builder">The resource where connection string will be injected.</param>
127+
/// <param name="source">The resource from which to extract the connection string.</param>
128+
/// <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>
129+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
130+
public static IResourceBuilder<TDestination> WithReference<TDestination>(this IResourceBuilder<TDestination> builder, IResourceBuilder<AzureOpenAIResource> source, string? resourceName = null)
131+
where TDestination : IResourceWithEnvironment
132+
{
133+
ArgumentNullException.ThrowIfNull(builder);
134+
ArgumentNullException.ThrowIfNull(source);
135+
136+
var resource = source.Resource;
137+
resourceName ??= resource.Name;
138+
139+
builder.WithReference((IResourceBuilder<IResourceWithConnectionString>)source, resourceName);
140+
141+
return builder.WithEnvironment(context =>
142+
{
143+
foreach (var deployment in resource.Deployments)
144+
{
145+
var variableName = $"ASPIRE__AZURE__AI__OPENAI__{resourceName}__MODELS__{deployment.Name}";
146+
context.EnvironmentVariables[variableName] = deployment.ModelName;
147+
}
148+
});
149+
}
116150
}

src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ namespace Aspire.Hosting.ApplicationModel;
1111
/// <param name="configureInfrastructure">Configures the underlying Azure resource using the CDK.</param>
1212
public class AzureOpenAIResource(string name, Action<AzureResourceInfrastructure> configureInfrastructure) :
1313
AzureProvisioningResource(name, configureInfrastructure),
14-
IResourceWithConnectionString
14+
IResourceWithConnectionString,
15+
IResourceWithEnvironment
1516
{
1617
private readonly List<AzureOpenAIDeployment> _deployments = [];
1718

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

34-
internal void AddDeployment(AzureOpenAIDeployment deployment)
35+
/// <summary>
36+
/// Adds an <see cref="AzureOpenAIDeployment"/> instance to the list of deployments.
37+
/// </summary>
38+
/// <param name="deployment">The <see cref="AzureOpenAIDeployment"/> instance to add.</param>
39+
public void AddDeployment(AzureOpenAIDeployment deployment)
3540
{
3641
ArgumentNullException.ThrowIfNull(deployment);
3742

src/Aspire.Hosting.Azure.CognitiveServices/PublicAPI.Unshipped.txt

+2
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@
55
Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment.AzureOpenAIDeployment(string! name, string! modelName, string! modelVersion, string? skuName = null, int? skuCapacity = null) -> void
66
Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment.SkuCapacity.set -> void
77
Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment.SkuName.set -> void
8+
Aspire.Hosting.ApplicationModel.AzureOpenAIResource.AddDeployment(Aspire.Hosting.ApplicationModel.AzureOpenAIDeployment! deployment) -> void
89
Aspire.Hosting.ApplicationModel.AzureOpenAIResource.AzureOpenAIResource(string! name, System.Action<Aspire.Hosting.Azure.AzureResourceInfrastructure!>! configureInfrastructure) -> void
10+
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>!

src/Aspire.Hosting/ResourceBuilderExtensions.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ private static Action<EnvironmentCallbackContext> CreateEndpointReferenceEnviron
323323

324324
/// <summary>
325325
/// 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).
326-
/// The format of the environment variable will be "ConnectionStrings__{sourceResourceName}={connectionString}."
326+
/// The format of the environment variable will be "ConnectionStrings__{sourceResourceName}={connectionString}".
327327
/// <para>
328328
/// Each resource defines the format of the connection string value. The
329329
/// underlying connection string value can be retrieved using <see cref="IResourceWithConnectionString.GetConnectionStringAsync(CancellationToken)"/>.
@@ -359,7 +359,7 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR
359359

360360
/// <summary>
361361
/// 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.
362-
/// Each endpoint defined on the project resource will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}."
362+
/// Each endpoint defined on the project resource will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}".
363363
/// </summary>
364364
/// <typeparam name="TDestination">The destination resource.</typeparam>
365365
/// <param name="builder">The resource where the service discovery information will be injected.</param>
@@ -406,7 +406,7 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR
406406

407407
/// <summary>
408408
/// Injects service discovery information from the specified endpoint into the project resource using the source resource's name as the service name.
409-
/// Each endpoint will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}."
409+
/// Each endpoint will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}".
410410
/// </summary>
411411
/// <typeparam name="TDestination">The destination resource.</typeparam>
412412
/// <param name="builder">The resource where the service discovery information will be injected.</param>
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Data.Common;
4+
using Aspire.Azure.AI.OpenAI;
55
using Azure.AI.OpenAI;
66
using Microsoft.Extensions.AI;
77
using Microsoft.Extensions.Configuration;
@@ -15,42 +15,41 @@ namespace Microsoft.Extensions.Hosting;
1515
/// </summary>
1616
public static class AspireAzureOpenAIClientBuilderChatClientExtensions
1717
{
18-
private const string DeploymentKey = "Deployment";
19-
private const string ModelKey = "Model";
20-
2118
/// <summary>
2219
/// Registers a singleton <see cref="IChatClient"/> in the services provided by the <paramref name="builder"/>.
2320
/// </summary>
2421
/// <param name="builder">An <see cref="AspireAzureOpenAIClientBuilder" />.</param>
2522
/// <param name="deploymentName">Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string.</param>
2623
/// <param name="configurePipeline">An optional method that can be used for customizing the <see cref="IChatClient"/> pipeline.</param>
2724
/// <remarks>Reads the configuration from "Aspire.Azure.AI.OpenAI" section.</remarks>
28-
public static void AddChatClient(
25+
public static AspireAzureOpenAIClientBuilder AddChatClient(
2926
this AspireAzureOpenAIClientBuilder builder,
3027
string? deploymentName = null,
3128
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null)
3229
{
3330
builder.HostBuilder.Services.AddSingleton(
3431
services => CreateChatClient(services, builder, deploymentName, configurePipeline));
32+
33+
return builder;
3534
}
3635

3736
/// <summary>
3837
/// Registers a keyed singleton <see cref="IChatClient"/> in the services provided by the <paramref name="builder"/>.
3938
/// </summary>
4039
/// <param name="builder">An <see cref="AspireAzureOpenAIClientBuilder" />.</param>
4140
/// <param name="serviceKey">The service key with which the <see cref="IChatClient"/> will be registered.</param>
42-
/// <param name="deploymentName">Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string.</param>
4341
/// <param name="configurePipeline">An optional method that can be used for customizing the <see cref="IChatClient"/> pipeline.</param>
4442
/// <remarks>Reads the configuration from "Aspire.Azure.AI.OpenAI" section.</remarks>
45-
public static void AddKeyedChatClient(
43+
public static AspireAzureOpenAIClientBuilder AddKeyedChatClient(
4644
this AspireAzureOpenAIClientBuilder builder,
4745
string serviceKey,
48-
string? deploymentName = null,
4946
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null)
5047
{
5148
builder.HostBuilder.Services.TryAddKeyedSingleton(
5249
serviceKey,
53-
(services, _) => CreateChatClient(services, builder, deploymentName, configurePipeline));
50+
(services, _) => CreateChatClient(services, builder, serviceKey, configurePipeline));
51+
52+
return builder;
5453
}
5554

5655
private static IChatClient CreateChatClient(
@@ -66,44 +65,35 @@ private static IChatClient CreateChatClient(
6665
var chatClientBuilder = new ChatClientBuilder(services);
6766
configurePipeline?.Invoke(chatClientBuilder);
6867

69-
deploymentName ??= GetRequiredDeploymentName(builder.HostBuilder.Configuration, builder.ConnectionName);
70-
71-
return chatClientBuilder.Use(openAiClient.AsChatClient(deploymentName));
72-
}
73-
74-
private static string GetRequiredDeploymentName(IConfiguration configuration, string connectionName)
75-
{
76-
string? deploymentName = null;
68+
var deploymentSettings = GetDeployments(builder.HostBuilder.Configuration, builder.ConnectionName);
7769

78-
if (configuration.GetConnectionString(connectionName) is string connectionString)
70+
// If no deployment name is provided, we search for the first one (and maybe only one) in configuration
71+
if (deploymentName is null)
7972
{
80-
var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString };
81-
var deploymentValue = ConnectionStringValue(connectionBuilder, DeploymentKey);
82-
var modelValue = ConnectionStringValue(connectionBuilder, ModelKey);
83-
if (deploymentValue is not null && modelValue is not null)
73+
deploymentName = deploymentSettings.Models.Keys.FirstOrDefault();
74+
75+
if (string.IsNullOrEmpty(deploymentName))
8476
{
85-
throw new InvalidOperationException(
86-
$"The connection string '{connectionName}' contains both '{DeploymentKey}' and '{ModelKey}' keys. Either of these may be specified, but not both.");
77+
throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure a deployment was defined .");
8778
}
88-
89-
deploymentName = deploymentValue ?? modelValue;
90-
}
91-
92-
var configurationSectionName = AspireAzureOpenAIExtensions.DefaultConfigSectionName;
93-
if (string.IsNullOrEmpty(deploymentName))
94-
{
95-
var configSection = configuration.GetSection(configurationSectionName);
96-
deploymentName = configSection[DeploymentKey];
9779
}
9880

99-
if (string.IsNullOrEmpty(deploymentName))
81+
if (!deploymentSettings.Models.TryGetValue(deploymentName, out var _))
10082
{
101-
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)}.");
83+
throw new InvalidOperationException($"An {nameof(IChatClient)} could not be configured. Ensure the deployment name '{deploymentName}' was defined .");
10284
}
10385

104-
return deploymentName;
86+
return chatClientBuilder.Use(openAiClient.AsChatClient(deploymentName));
10587
}
10688

107-
private static string? ConnectionStringValue(DbConnectionStringBuilder connectionString, string key)
108-
=> connectionString.TryGetValue(key, out var value) ? value as string : null;
89+
private static DeploymentModelSettings GetDeployments(IConfiguration configuration, string connectionName)
90+
{
91+
var configurationSectionName = $"{AspireAzureOpenAIExtensions.DefaultConfigSectionName}:{connectionName}";
92+
var configSection = configuration.GetSection(configurationSectionName);
93+
94+
var settings = new DeploymentModelSettings();
95+
configSection.Bind(settings);
96+
97+
return settings;
98+
}
10999
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Azure.AI.OpenAI;
5+
6+
/// <summary>
7+
/// Helper class to bind the deployment models from configuration (deployment names and model names).
8+
/// More specifically, it binds the "Aspire:Azure:AI:OpenAI:{resourceName}:Models" section.
9+
/// </summary>
10+
internal sealed class DeploymentModelSettings
11+
{
12+
/// <summary>
13+
/// Gets or sets the dictionary of deployment names and model names.
14+
/// </summary>
15+
/// <remarks>
16+
/// For instance <code>{ ["chat"] = "gpt-4o" }</code>.
17+
/// </remarks>
18+
public Dictionary<string, string> Models { get; set; } = [];
19+
}

0 commit comments

Comments
 (0)