From cacc720c0e991aedca2a659e588904f3cd4c429d Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 5 May 2025 14:15:54 +1000 Subject: [PATCH 01/17] Initial implementation of Inference SDK as an integration Fixes #9011 --- Aspire.sln | 15 ++ Directory.Packages.props | 2 + .../Aspire.Azure.AI.Inference.csproj | 30 +++ .../AspireAzureAIInferenceExtensions.cs | 178 ++++++++++++++++++ .../AspireChatCompletionsClientBuilder.cs | 28 +++ .../ChatCompletionsClientSettings.cs | 93 +++++++++ 6 files changed, 346 insertions(+) create mode 100644 src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj create mode 100644 src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs create mode 100644 src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs create mode 100644 src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs diff --git a/Aspire.sln b/Aspire.sln index 4673be1ffb2..5847f351948 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -665,6 +665,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.ContainerRegistry", "src\Aspire.Hosting.Azure.ContainerRegistry\Aspire.Hosting.Azure.ContainerRegistry.csproj", "{6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.AI.Inference", "src\Components\Aspire.Azure.AI.Inference\Aspire.Azure.AI.Inference.csproj", "{F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -3891,6 +3893,18 @@ Global {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x64.Build.0 = Release|Any CPU {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x86.ActiveCfg = Release|Any CPU {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x86.Build.0 = Release|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Debug|x64.ActiveCfg = Debug|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Debug|x64.Build.0 = Debug|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Debug|x86.ActiveCfg = Debug|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Debug|x86.Build.0 = Debug|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Release|Any CPU.Build.0 = Release|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Release|x64.ActiveCfg = Release|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Release|x64.Build.0 = Release|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Release|x86.ActiveCfg = Release|Any CPU + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4209,6 +4223,7 @@ Global {8FCA0CFA-7823-6A2F-342A-107A994915B0} = {C424395C-1235-41A4-BF55-07880A04368C} {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7} = {C424395C-1235-41A4-BF55-07880A04368C} {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4} diff --git a/Directory.Packages.props b/Directory.Packages.props index b3825726499..9e224b2537b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + @@ -153,6 +154,7 @@ + diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj new file mode 100644 index 00000000000..6508abef660 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -0,0 +1,30 @@ + + + + $(DefaultTargetFramework) + true + $(ComponentAzurePackageTags) ai + A client for Azure AI Inference SDK that integrates with Aspire, including logging and telemetry. + $(SharedDir)Azure_256x.png + $(NoWarn);SYSLIB1100;SYSLIB1101 + + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs new file mode 100644 index 00000000000..4e06b0a5b99 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Azure; +using Azure.AI.Inference; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Identity; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for adding Azure AI Inference services to an Aspire application. +/// +public static class AspireAzureAIInferenceExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Azure:AI:Inference"; + + /// + /// Adds a to the application and configures it with the specified settings. + /// + /// The to add the client to. + /// The name of the client. This is used to retrieve the connection string from configuration. + /// An optional callback to configure the . + /// An optional callback to configure the for the client. + /// An that can be used to further configure the client. + /// Thrown when endpoint is missing from settings. + /// + /// + /// The client is registered as a singleton with a keyed service. + /// + /// + /// Configuration is loaded from the "Aspire:Azure:AI:Inference" section, and can be supplemented with a connection string named after the parameter. + /// + /// + public static AspireChatCompletionsClientBuilder AddChatCompletionsClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureClient = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + var settings = new ChatCompletionsClientServiceComponent().AddClient( + builder, + DefaultConfigSectionName, + configureClient, + configureClientBuilder, + connectionName, + serviceKey: null); + + return new AspireChatCompletionsClientBuilder(builder, serviceKey: null, settings.ModelId, settings.DisableTracing); + } + + /// + /// Adds a to the application and configures it with the specified settings. + /// + /// The to add the client to. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional callback to configure the . + /// An optional callback to configure the for the client. + /// An that can be used to further configure the client. + /// Thrown when endpoint is missing from settings. + /// + /// + /// The client is registered as a singleton with a keyed service. + /// + /// + /// Configuration is loaded from the "Aspire:Azure:AI:Inference" section, and can be supplemented with a connection string named after the parameter. + /// + /// + public static AspireChatCompletionsClientBuilder AddKeyedChatCompletionsClient( + this IHostApplicationBuilder builder, + string name, + Action? configureClient = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var settings = new ChatCompletionsClientServiceComponent().AddClient( + builder, + DefaultConfigSectionName, + configureClient, + configureClientBuilder, + name, + serviceKey: name); + + return new AspireChatCompletionsClientBuilder(builder, serviceKey: name, settings.ModelId, settings.DisableTracing); + } + + private sealed class ChatCompletionsClientServiceComponent : AzureComponent + { + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, + ChatCompletionsClientSettings settings, + string connectionName, string + configurationSectionName) + { + return azureFactoryBuilder.AddClient((options, _, _) => + { + if (settings.Endpoint is null) + { + throw new InvalidOperationException($"An ChatCompletionsClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a '{nameof(ChatCompletionsClientSettings.Endpoint)}' or '{nameof(ChatCompletionsClientSettings.Key)}' in the '{configurationSectionName}' configuration section."); + } + else + { + // Connect to Azure AI Foundry using key auth + if (!string.IsNullOrEmpty(settings.Key)) + { + var credential = new AzureKeyCredential(settings.Key); + return new ChatCompletionsClient(settings.Endpoint, credential, options); + } + else + { + return new ChatCompletionsClient(settings.Endpoint, settings.TokenCredential ?? new DefaultAzureCredential(), options); + } + } + }); + } + + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) + { +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 + } + + protected override void BindSettingsToConfiguration(ChatCompletionsClientSettings settings, IConfiguration configuration) + => configuration.Bind(settings); + + protected override IHealthCheck CreateHealthCheck(ChatCompletionsClient client, ChatCompletionsClientSettings settings) + => throw new NotImplementedException(); + + protected override bool GetHealthCheckEnabled(ChatCompletionsClientSettings settings) + => false; + + protected override bool GetMetricsEnabled(ChatCompletionsClientSettings settings) + => !settings.DisableMetrics; + + protected override TokenCredential? GetTokenCredential(ChatCompletionsClientSettings settings) + => settings.TokenCredential; + + protected override bool GetTracingEnabled(ChatCompletionsClientSettings settings) + => !settings.DisableTracing; + } + + /// + /// Creates a from the registered in the service collection. + /// + /// + /// + public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBuilder builder) => + builder.Builder.Services.AddChatClient(services => + { + var chatCompletionsClient = !string.IsNullOrEmpty(builder.ServiceKey) ? + services.GetRequiredService() : + services.GetRequiredKeyedService(builder.ServiceKey); + + var result = chatCompletionsClient.AsIChatClient(); + + if (builder.DisableTracing) + { + return result; + } + + var loggerFactory = services.GetService(); + return new OpenTelemetryChatClient(result, loggerFactory?.CreateLogger(typeof(OpenTelemetryChatClient))); + }); +} diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs b/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs new file mode 100644 index 00000000000..d53553e1b3f --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs @@ -0,0 +1,28 @@ +// 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.Inference; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides a builder for configuring and integrating an Aspire Chat Completions client into a host application. +/// +/// This class is used to configure the necessary parameters for creating an Aspire Chat Completions +/// client, such as the host application builder, service key, and optional model ID. It is intended for internal use +/// within the application setup process. +/// The with which services are being registered. +/// The service key used to register the service, if any. +/// The name of the model (deployment) in Azure AI Foundry. +/// A flag to indicate whether tracing should be disabled. +public class AspireChatCompletionsClientBuilder( + IHostApplicationBuilder builder, + string? serviceKey, + string? modelId, + bool disableTracing) +{ + internal bool DisableTracing { get; } = disableTracing; + internal IHostApplicationBuilder Builder { get; } = builder; + internal string? ServiceKey { get; } = serviceKey; + internal string? ModelId { get; } = modelId; +} diff --git a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs new file mode 100644 index 00000000000..a6f242ca1ef --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Core; +using System.Data.Common; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Represents configuration settings for Azure AI Chat Completions client. +/// +public sealed class ChatCompletionsClientSettings +{ + /// + /// Gets or sets the name of the AI model to use for chat completions. + /// + public string? ModelName { get; set; } + + /// + /// Gets or sets the ID of the AI model to use for chat completions. + /// + public string? ModelId { get; set; } + + /// + /// Gets or sets the endpoint URI for the Azure AI service. + /// + public Uri? Endpoint { get; set; } + + /// + /// Gets or sets the token credential used for Azure authentication. + /// + public TokenCredential? TokenCredential { get; set; } + + /// + /// Gets or sets the API key used for authentication with the Azure AI service. + /// + public string? Key { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not. + /// + /// + /// /// Azure AI Inference telemetry follows the pattern of Azure SDKs Diagnostics. + /// + public bool DisableMetrics { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// Azure AI Inference telemetry follows the pattern of Azure SDKs Diagnostics. + /// + public bool DisableTracing { get; set; } + + /// + /// Parses a connection string and populates the settings properties. + /// + /// The connection string containing configuration values. + /// + /// The connection string can contain the following keys: + /// - ModelName: The name of the AI model + /// - ModelId: The ID of the AI model + /// - Endpoint: The service endpoint URI + /// - Key: The API key for authentication + /// + internal void ParseConnectionString(string connectionString) + { + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + if (connectionBuilder.TryGetValue(nameof(ModelName), out var model)) + { + ModelName = model.ToString(); + } + + if (connectionBuilder.TryGetValue(nameof(ModelId), out var modelId)) + { + ModelId = modelId.ToString(); + } + + if (connectionBuilder.TryGetValue(nameof(Endpoint), out var endpoint) && Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out var serviceUri)) + { + Endpoint = serviceUri; + } + + if (connectionBuilder.TryGetValue(nameof(Key), out var key) && !string.IsNullOrWhiteSpace(key.ToString())) + { + Key = key.ToString(); + } + } +} From 5f101918b9322ddfbb3d155473f95b23fd07e90d Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 5 May 2025 16:02:28 +1000 Subject: [PATCH 02/17] Making properties public --- .../AspireChatCompletionsClientBuilder.cs | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs b/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs index d53553e1b3f..1d9a836cd96 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs @@ -11,18 +11,33 @@ namespace Microsoft.Extensions.Hosting; /// This class is used to configure the necessary parameters for creating an Aspire Chat Completions /// client, such as the host application builder, service key, and optional model ID. It is intended for internal use /// within the application setup process. -/// The with which services are being registered. +/// The with which services are being registered. /// The service key used to register the service, if any. -/// The name of the model (deployment) in Azure AI Foundry. +/// The id of the deployment in Azure AI Foundry. /// A flag to indicate whether tracing should be disabled. public class AspireChatCompletionsClientBuilder( - IHostApplicationBuilder builder, + IHostApplicationBuilder hostBuilder, string? serviceKey, - string? modelId, + string? deploymentId, bool disableTracing) { - internal bool DisableTracing { get; } = disableTracing; - internal IHostApplicationBuilder Builder { get; } = builder; - internal string? ServiceKey { get; } = serviceKey; - internal string? ModelId { get; } = modelId; + /// + /// Gets a flag indicating whether tracing should be disabled. + /// + public bool DisableTracing { get; } = disableTracing; + + /// + /// Gets the with which services are being registered. + /// + public IHostApplicationBuilder HostBuilder { get; } = hostBuilder ?? throw new ArgumentNullException(nameof(hostBuilder)); + + /// + /// Gets the service key used to register the service, if any. + /// + public string? ServiceKey { get; } = serviceKey; + + /// + /// The ID of the deployment in Azure AI Foundry. + /// + public string? DeploymentId { get; } = deploymentId; } From 3a2cc94579433749e7b28c58ce2a558524e99400 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 5 May 2025 16:57:34 +1000 Subject: [PATCH 03/17] Fixing missed refactor code --- .../AspireAzureAIInferenceExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 4e06b0a5b99..5dbc475a0c0 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Hosting; @@ -159,7 +158,7 @@ protected override bool GetTracingEnabled(ChatCompletionsClientSettings settings /// /// public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBuilder builder) => - builder.Builder.Services.AddChatClient(services => + builder.HostBuilder.Services.AddChatClient(services => { var chatCompletionsClient = !string.IsNullOrEmpty(builder.ServiceKey) ? services.GetRequiredService() : From 51ac74cf8e3f0db5d12117350873908a9e809545 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 6 May 2025 09:30:52 +1000 Subject: [PATCH 04/17] Apply suggestions from code review Co-authored-by: Eric Erhardt --- .../Aspire.Azure.AI.Inference.csproj | 2 +- .../AspireAzureAIInferenceExtensions.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj index 6508abef660..a754e318375 100644 --- a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -7,7 +7,7 @@ A client for Azure AI Inference SDK that integrates with Aspire, including logging and telemetry. $(SharedDir)Azure_256x.png $(NoWarn);SYSLIB1100;SYSLIB1101 - + true diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 5dbc475a0c0..9008ba858a8 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -171,7 +171,8 @@ public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBu return result; } - var loggerFactory = services.GetService(); - return new OpenTelemetryChatClient(result, loggerFactory?.CreateLogger(typeof(OpenTelemetryChatClient))); + return new ChatClientBuilder(result) + .UseOpenTelemetry() + .Build(); }); } From f7230edc52d412f779e9b342a3e3274e65d7261d Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 6 May 2025 09:34:54 +1000 Subject: [PATCH 05/17] Updating from code review feedback --- .../AspireAzureAIInferenceExtensions.cs | 4 +- .../Aspire.Azure.AI.Inference/AssemblyInfo.cs | 15 ++ .../ChatCompletionsClientSettings.cs | 28 ++-- .../ConfigurationSchema.json | 148 ++++++++++++++++++ 4 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 src/Components/Aspire.Azure.AI.Inference/AssemblyInfo.cs create mode 100644 src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 9008ba858a8..44b1d769755 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -56,7 +56,7 @@ public static AspireChatCompletionsClientBuilder AddChatCompletionsClient( connectionName, serviceKey: null); - return new AspireChatCompletionsClientBuilder(builder, serviceKey: null, settings.ModelId, settings.DisableTracing); + return new AspireChatCompletionsClientBuilder(builder, serviceKey: null, settings.DeploymentId, settings.DisableTracing); } /// @@ -93,7 +93,7 @@ public static AspireChatCompletionsClientBuilder AddKeyedChatCompletionsClient( name, serviceKey: name); - return new AspireChatCompletionsClientBuilder(builder, serviceKey: name, settings.ModelId, settings.DisableTracing); + return new AspireChatCompletionsClientBuilder(builder, serviceKey: name, settings.DeploymentId, settings.DisableTracing); } private sealed class ChatCompletionsClientServiceComponent : AzureComponent diff --git a/src/Components/Aspire.Azure.AI.Inference/AssemblyInfo.cs b/src/Components/Aspire.Azure.AI.Inference/AssemblyInfo.cs new file mode 100644 index 00000000000..cf504c5b55a --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/AssemblyInfo.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Azure.AI.Inference; +using Microsoft.Extensions.Hosting; + +[assembly: ConfigurationSchema("Aspire:Azure:AI:Inference", typeof(ChatCompletionsClientSettings))] +[assembly: ConfigurationSchema("Aspire:Azure:AI:Inference:ClientOptions", typeof(AzureAIInferenceClientOptions))] + +[assembly: LoggingCategories( + "Azure", + "Azure.Core", + "Azure.Identity" +)] diff --git a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs index a6f242ca1ef..afeb97a65d6 100644 --- a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs +++ b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs @@ -1,25 +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.Core; using System.Data.Common; +using Aspire.Azure.Common; +using Azure.Core; namespace Microsoft.Extensions.Hosting; /// /// Represents configuration settings for Azure AI Chat Completions client. /// -public sealed class ChatCompletionsClientSettings +public sealed class ChatCompletionsClientSettings : IConnectionStringSettings { /// - /// Gets or sets the name of the AI model to use for chat completions. - /// - public string? ModelName { get; set; } - - /// - /// Gets or sets the ID of the AI model to use for chat completions. + /// Gets or sets the ID of the AI model deployment to use for chat completions. /// - public string? ModelId { get; set; } + public string? DeploymentId { get; set; } /// /// Gets or sets the endpoint URI for the Azure AI service. @@ -58,26 +54,20 @@ public sealed class ChatCompletionsClientSettings /// The connection string containing configuration values. /// /// The connection string can contain the following keys: - /// - ModelName: The name of the AI model - /// - ModelId: The ID of the AI model + /// - DeploymentId: The ID of the AI model /// - Endpoint: The service endpoint URI /// - Key: The API key for authentication /// - internal void ParseConnectionString(string connectionString) + void IConnectionStringSettings.ParseConnectionString(string? connectionString) { var connectionBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; - if (connectionBuilder.TryGetValue(nameof(ModelName), out var model)) - { - ModelName = model.ToString(); - } - - if (connectionBuilder.TryGetValue(nameof(ModelId), out var modelId)) + if (connectionBuilder.TryGetValue(nameof(DeploymentId), out var modelId)) { - ModelId = modelId.ToString(); + DeploymentId = modelId.ToString(); } if (connectionBuilder.TryGetValue(nameof(Endpoint), out var endpoint) && Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out var serviceUri)) diff --git a/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json new file mode 100644 index 00000000000..9596f9e88b6 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json @@ -0,0 +1,148 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Azure": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Azure.Core": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Azure.Identity": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "type": "object", + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Azure": { + "type": "object", + "properties": { + "AI": { + "type": "object", + "properties": { + "Inference": { + "type": "object", + "properties": { + "ClientOptions": { + "type": "object", + "properties": { + "Diagnostics": { + "type": "object", + "properties": { + "ApplicationId": { + "type": "string", + "description": "Gets or sets the value sent as the first part of \"User-Agent\" headers for all requests issues by this client. Defaults to 'Azure.Core.DiagnosticsOptions.DefaultApplicationId'." + }, + "DefaultApplicationId": { + "type": "string", + "description": "Gets or sets the default application id. Default application id would be set on all instances." + }, + "IsDistributedTracingEnabled": { + "type": "boolean", + "description": "Gets or sets value indicating whether distributed tracing activities ('System.Diagnostics.Activity') are going to be created for the clients methods calls and HTTP calls." + }, + "IsLoggingContentEnabled": { + "type": "boolean", + "description": "Gets or sets value indicating if request or response content should be logged." + }, + "IsLoggingEnabled": { + "type": "boolean", + "description": "Get or sets value indicating whether HTTP pipeline logging is enabled." + }, + "IsTelemetryEnabled": { + "type": "boolean", + "description": "Gets or sets value indicating whether the \"User-Agent\" header containing 'Azure.Core.DiagnosticsOptions.ApplicationId', client library package name and version, 'System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription' and 'System.Runtime.InteropServices.RuntimeInformation.OSDescription' should be sent. The default value can be controlled process wide by setting AZURE_TELEMETRY_DISABLED to true, false, 1 or 0." + }, + "LoggedContentSizeLimit": { + "type": "integer", + "description": "Gets or sets value indicating maximum size of content to log in bytes. Defaults to 4096." + }, + "LoggedHeaderNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets a list of header names that are not redacted during logging." + }, + "LoggedQueryParameters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets a list of query parameter names that are not redacted during logging." + } + }, + "description": "Gets the client diagnostic options." + }, + "Retry": { + "type": "object", + "properties": { + "Delay": { + "type": "string", + "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", + "description": "The delay between retry attempts for a fixed approach or the delay on which to base calculations for a backoff-based approach. If the service provides a Retry-After response header, the next retry will be delayed by the duration specified by the header value." + }, + "MaxDelay": { + "type": "string", + "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", + "description": "The maximum permissible delay between retry attempts when the service does not provide a Retry-After response header. If the service provides a Retry-After response header, the next retry will be delayed by the duration specified by the header value." + }, + "MaxRetries": { + "type": "integer", + "description": "The maximum number of retry attempts before giving up." + }, + "Mode": { + "enum": [ + "Fixed", + "Exponential" + ], + "description": "The approach to use for calculating retry delays." + }, + "NetworkTimeout": { + "type": "string", + "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", + "description": "The timeout applied to an individual network operations." + } + }, + "description": "Gets the client retry options." + } + }, + "description": "Client options for Azure.AI.Inference library clients." + }, + "DeploymentId": { + "type": "string", + "description": "Gets or sets the ID of the AI model deployment to use for chat completions." + }, + "DisableMetrics": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not." + }, + "DisableTracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not." + }, + "Endpoint": { + "type": "string", + "format": "uri", + "description": "Gets or sets the endpoint URI for the Azure AI service." + }, + "Key": { + "type": "string", + "description": "Gets or sets the API key used for authentication with the Azure AI service." + } + }, + "description": "Represents configuration settings for Azure AI Chat Completions client." + } + } + } + } + } + } + } + } +} From e08b44482a45e6a83856803ddcd9007db4e3ad1c Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 6 May 2025 10:53:45 +1000 Subject: [PATCH 06/17] Adding tests --- Aspire.sln | 18 +++- .../ChatCompletionsClientSettings.cs | 9 ++ .../ConfigurationSchema.json | 4 + .../Aspire.Azure.AI.Inference.Tests.csproj | 20 +++++ .../AspireAzureAIInferenceExtensionTests.cs | 87 +++++++++++++++++++ .../AspireAzureAIInferencePublicApiTests.cs | 66 ++++++++++++++ .../ConformanceTests.cs | 83 ++++++++++++++++++ 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj create mode 100644 tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs create mode 100644 tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs create mode 100644 tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs diff --git a/Aspire.sln b/Aspire.sln index e6534d6cfc3..005926d743d 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -675,6 +675,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureAppService.ApiService" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureAppService.AppHost", "playground\AzureAppService\AzureAppService.AppHost\AzureAppService.AppHost.csproj", "{2C879943-DF34-44FA-B2C3-29D97F24DD76}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.AI.Inference.Tests", "tests\Aspire.Azure.AI.Inference.Tests\Aspire.Azure.AI.Inference.Tests.csproj", "{58A5A310-BDB7-494F-8BC7-BEC66430F4A8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -3949,6 +3951,18 @@ Global {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x64.Build.0 = Release|Any CPU {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x86.ActiveCfg = Release|Any CPU {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x86.Build.0 = Release|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Debug|x64.Build.0 = Debug|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Debug|x86.Build.0 = Debug|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Release|Any CPU.Build.0 = Release|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Release|x64.ActiveCfg = Release|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Release|x64.Build.0 = Release|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Release|x86.ActiveCfg = Release|Any CPU + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4266,13 +4280,13 @@ Global {192747A2-9338-DECF-5C8C-28EB8E13829B} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {8FCA0CFA-7823-6A2F-342A-107A994915B0} = {C424395C-1235-41A4-BF55-07880A04368C} {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7} = {C424395C-1235-41A4-BF55-07880A04368C} - {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3} = {77CFE74A-32EE-400C-8930-5025E8555256} + {F53BA5F3-31ED-E71D-DABD-DF4C9DF59079} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {5DDF8E89-FBBD-4A6F-BF32-7D2140724941} = {77CFE74A-32EE-400C-8930-5025E8555256} {2D9974C2-3AB2-FBFD-5156-080508BB7449} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {A617DC84-65DA-41B5-B378-6C2F569CEE48} = {2D9974C2-3AB2-FBFD-5156-080508BB7449} {2C879943-DF34-44FA-B2C3-29D97F24DD76} = {2D9974C2-3AB2-FBFD-5156-080508BB7449} + {58A5A310-BDB7-494F-8BC7-BEC66430F4A8} = {C424395C-1235-41A4-BF55-07880A04368C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4} diff --git a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs index afeb97a65d6..088295896fc 100644 --- a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs +++ b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs @@ -4,6 +4,7 @@ using System.Data.Common; using Aspire.Azure.Common; using Azure.Core; +using Microsoft.Identity.Client.Platforms.Features.DesktopOs.Kerberos; namespace Microsoft.Extensions.Hosting; @@ -12,6 +13,14 @@ namespace Microsoft.Extensions.Hosting; /// public sealed class ChatCompletionsClientSettings : IConnectionStringSettings { + /// + /// Gets or sets the connection string used to connect to the AI Foundry account. + /// + /// + /// If is set, it overrides and . + /// + public string? ConnectionString { get; set; } + /// /// Gets or sets the ID of the AI model deployment to use for chat completions. /// diff --git a/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json index 9596f9e88b6..adcd4b79017 100644 --- a/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json +++ b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json @@ -114,6 +114,10 @@ }, "description": "Client options for Azure.AI.Inference library clients." }, + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string used to connect to the AI Foundry account." + }, "DeploymentId": { "type": "string", "description": "Gets or sets the ID of the AI model deployment to use for chat completions." diff --git a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj new file mode 100644 index 00000000000..e438dc332f6 --- /dev/null +++ b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj @@ -0,0 +1,20 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + + + + + + + + diff --git a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs new file mode 100644 index 00000000000..b5da8df3399 --- /dev/null +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs @@ -0,0 +1,87 @@ +// 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.Inference; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.AI.Inference.Tests; + +public class AspireAzureAIInferenceExtensionTests +{ + private const string ConnectionString = "Endpoint=https://fakeendpoint;Key=fakekey;DeploymentId=deployment"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadsFromConnectionStringsCorrectly(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:inference", ConnectionString) + ]); + if (useKeyed) + { + builder.AddKeyedChatCompletionsClient("inference"); + } + else + { + builder.AddChatCompletionsClient("inference"); + } + using var host = builder.Build(); + var client = useKeyed ? + host.Services.GetKeyedService("inference") : + host.Services.GetService(); + + Assert.NotNull(client); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionStringCanBeSetInCode(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:inference", "Endpoint=unused;Key=myAccount;DeploymentId=unused") + ]); + + if (useKeyed) + { + builder.AddKeyedChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); + } + else + { + builder.AddChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); + } + + using var host = builder.Build(); + + var client = useKeyed ? + host.Services.GetKeyedService("inference") : + host.Services.GetService(); + + Assert.NotNull(client); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:inference1", ConnectionString), + new KeyValuePair("ConnectionStrings:inference2", ConnectionString + "2") + ]); + builder.AddKeyedChatCompletionsClient("inference1"); + builder.AddKeyedChatCompletionsClient("inference2"); + using var host = builder.Build(); + var client1 = host.Services.GetKeyedService("inference1"); + var client2 = host.Services.GetKeyedService("inference2"); + Assert.NotNull(client1); + Assert.NotNull(client2); + + Assert.NotSame(client1, client2); + } +} diff --git a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs new file mode 100644 index 00000000000..4ac283e2b3e --- /dev/null +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.AI.Inference.Tests; + +public class AspireAzureAIInferencePublicApiTests +{ + [Fact] + public void AddChatCompletionsClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + const string connectionName = "aiinference"; + + var action = () => builder.AddChatCompletionsClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddChatCompletionsClientShouldThrowWhenConnectionNameIsNullOrEmpty(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + var connectionName = isNull ? null! : string.Empty; + + var action = () => builder.AddChatCompletionsClient(connectionName); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddKeyedChatCompletionsClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + const string name = "aiinference"; + + var action = () => builder.AddKeyedChatCompletionsClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddKeyedChatCompletionsClientShouldThrowWhenNameIsNullOrEmpty(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + var name = isNull ? null! : string.Empty; + + var action = () => builder.AddKeyedChatCompletionsClient(name); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +} diff --git a/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs new file mode 100644 index 00000000000..82a25a9c9b7 --- /dev/null +++ b/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Azure.AI.Inference; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Azure.AI.Inference.Tests; +public class ConformanceTests : ConformanceTests +{ + private const string Endpoint = "https://fakeendpoint"; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Azure.AI.Inference.ChatCompletionsClient"; + + protected override string[] RequiredLogCategories => ["Azure.Identity"]; + + protected override string? ConfigurationSectionName => "Aspire:Azure:AI:Inference"; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Azure": { + "AI": { + "Inference": { + "Endpoint": "http://YOUR_URI", + "Key": "YOUR_KEY", + "DeploymentId": "DEPLOYMENT_ID", + "DisableTracing": false, + "DisableMetrics": false + } + } + } + } + } + """; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[] + { + new(CreateConfigKey("Aspire:Azure:AI:Inference", key, "Endpoint"), Endpoint) + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddChatCompletionsClient("inference", ConfigureCredentials); + } + else + { + builder.AddChatCompletionsClient(key, ConfigureCredentials); + } + + void ConfigureCredentials(ChatCompletionsClientSettings settings) + { + if (CanConnectToServer) + { + settings.TokenCredential = new DefaultAzureCredential(); + } + + configure?.Invoke(settings); + } + } + + protected override void SetHealthCheck(ChatCompletionsClientSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetMetrics(ChatCompletionsClientSettings options, bool enabled) + => options.DisableMetrics = !enabled; + + protected override void SetTracing(ChatCompletionsClientSettings options, bool enabled) + => options.DisableTracing = !enabled; + + protected override void TriggerActivity(ChatCompletionsClient service) + { + service.Complete(new ChatCompletionsOptions { Messages = [new ChatRequestUserMessage("dummy")] }); + } +} From efdc6f8e33fc393d3e78d191daaa97c5bc1a0323 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Wed, 7 May 2025 11:43:02 +1000 Subject: [PATCH 07/17] Updating based on feedback --- .../ChatCompletionsClientSettings.cs | 33 +++++++++++++++++-- .../ConfigurationSchema.json | 3 +- src/Components/Aspire_Components_Progress.md | 1 + src/Components/Telemetry.md | 11 ++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs index 088295896fc..5806fcf4c9e 100644 --- a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs +++ b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs @@ -13,6 +13,8 @@ namespace Microsoft.Extensions.Hosting; /// public sealed class ChatCompletionsClientSettings : IConnectionStringSettings { + private bool? _disableTracing; + /// /// Gets or sets the connection string used to connect to the AI Foundry account. /// @@ -53,9 +55,36 @@ public sealed class ChatCompletionsClientSettings : IConnectionStringSettings /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. /// /// - /// Azure AI Inference telemetry follows the pattern of Azure SDKs Diagnostics. + /// ServiceBus ActivitySource support in Azure SDK is experimental, the shape of Activities may change in the future without notice. + /// It can be enabled by setting "Azure.Experimental.EnableActivitySource" switch to true. + /// Or by setting "AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE" environment variable to "true". /// - public bool DisableTracing { get; set; } + /// + /// The default value is . + /// + public bool DisableTracing + { + get { return _disableTracing ??= !GetTracingDefaultValue(); } + set { _disableTracing = value; } + } + + // Defaults DisableTracing to false if the experimental switch is set + // TODO: remove this when ActivitySource support is no longer experimental + private static bool GetTracingDefaultValue() + { + if (AppContext.TryGetSwitch("Azure.Experimental.EnableActivitySource", out var enabled)) + { + return enabled; + } + + var envVar = Environment.GetEnvironmentVariable("AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE"); + if (envVar is not null && (envVar.Equals("true", StringComparison.OrdinalIgnoreCase) || envVar.Equals("1"))) + { + return true; + } + + return false; + } /// /// Parses a connection string and populates the settings properties. diff --git a/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json index adcd4b79017..21572287031 100644 --- a/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json +++ b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json @@ -128,7 +128,8 @@ }, "DisableTracing": { "type": "boolean", - "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not." + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.", + "default": false }, "Endpoint": { "type": "string", diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index dcf29bfb7af..4110404974e 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -11,6 +11,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | Microsoft.EntityFramework.Cosmos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | Microsoft.EntityFrameworkCore.SqlServer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | MongoDB.Driver | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Azure.AI.Inference | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | Azure.AI.OpenAI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | Azure.Data.Tables | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Messaging.EventHubs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index c19559187e9..d7600092f54 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -1,5 +1,14 @@ # Log categories, activity source names and metric names +Aspire.Azure.AI.Inference: +- Log categories: + - "Azure.Core" + - "Azure.Identity" +- Activity source names: + - "Azure.AI.Inference.*" +- Metric names: + - none (currently not supported by the Azure SDK) + Aspire.Azure.AI.OpenAI: - Log categories: - "Azure.Core" @@ -270,7 +279,7 @@ Aspire.Npgsql.EntityFrameworkCore.PostgreSQL: Aspire.OpenAI: - Log categories: - - none + - none - Activity source names: - "OpenAI.*" - Metric names: From ede53dafd2b3b4c6ffc9dd6ab8201d7d8a22e490 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Sat, 10 May 2025 09:50:10 +1000 Subject: [PATCH 08/17] Adding readme --- .../Aspire.Azure.AI.Inference/README.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/Components/Aspire.Azure.AI.Inference/README.md diff --git a/src/Components/Aspire.Azure.AI.Inference/README.md b/src/Components/Aspire.Azure.AI.Inference/README.md new file mode 100644 index 00000000000..8e317763921 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/README.md @@ -0,0 +1,132 @@ +# Aspire.Azure.AI.Inference library + +Registers [ChatCompletionsClient](https://learn.microsoft.com/dotnet/api/azure.ai.inference.chatcompletionsclient) as a singleton in the DI container for connecting to Azure AI Foundry and GitHub Models. Enables corresponding metrics, logging and telemetry. + +## Getting started + +### Prerequisites + +- Azure subscription - [create one for free](https://azure.microsoft.com/free/) +- Azure AI Foundry Resource - [create an Azure AI Foundry resource](https://learn.microsoft.com/azure/ai-foundry/how-to/develop/sdk-overview?tabs=sync&pivots=programming-language-csharp) + +### Install the package + +Install the .NET Aspire Azure Inference library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Azure.AI.Inference +``` + +## Usage example + +In the _AppHost.cs_ file of your project, call the `AddChatCompletionsClient` extension method to register a `ChatCompletionsClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddChatCompletionsClient("connectionName"); +``` + +You can then retrieve the `ChatCompletionsClient` instance using dependency injection. For example, to retrieve the client from a Web API controller: + +```csharp +private readonly ChatCompletionsClient _client; + +public CognitiveController(ChatCompletionsClient client) +{ + _client = client; +} +``` + +See the [Azure AI Foundry SDK quickstarts](https://learn.microsoft.com/azure/ai-foundry/how-to/develop/sdk-overview) for examples on using the `ChatCompletionsClient`. + +## Configuration + +The .NET Aspire Azure AI Inference library provides multiple options to configure the Azure AI Foundry Service based on the requirements and conventions of your project. Note that either an `Endpoint` and `DeploymentId`, or a `ConnectionString` is required to be supplied. + +### Use a connection string + +A connection can be constructed from the __Keys, Deployment ID and Endpoint__ tab with the format `Endpoint={endpoint};Key={key};DeploymentId={deploymentId}`. You can provide the name of the connection string when calling `builder.AddChatCompletionsClient()`: + +```csharp +builder.AddChatCompletionsClient("connectionName"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section. Two connection formats are supported: + +#### Azure AI Foundry Endpoint + +The recommended approach is to use an Endpoint, which works with the `ChatCompletionsClientSettings.Credential` property to establish a connection. If no credential is configured, the [DefaultAzureCredential](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential) is used. + +```json +{ + "ConnectionStrings": { + "connectionName": "Endpoint=https://{endpoint}/;DeploymentId={deploymentName}" + } +} +``` + +#### Connection string + +Alternatively, a custom connection string can be used. + +```json +{ + "ConnectionStrings": { + "connectionName": "Endpoint=https://{endpoint}/;Key={account_key};DeploymentId={deploymentName}" + } +} +``` + +### Use configuration providers + +The .NET Aspire Azure AI Inference library supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `ChatCompletionsClientSettings` and `AzureAIInferenceClientOptions` from configuration by using the `Aspire:Azure:AI:Inference` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Azure": { + "AI": { + "Inference": { + "DisableTracing": false, + "ClientOptions": { + "UserAgentApplicationId": "myapp" + } + } + } + } + } +} +``` + +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable tracing from code: + +```csharp +builder.AddChatCompletionsClient("connectionName", settings => settings.DisableTracing = true); +``` + +You can also setup the [AzureAIInferenceClientOptions](https://learn.microsoft.com/dotnet/api/azure.ai.inference.AzureAIInferenceClientOptions) using the optional `Action> configureClientBuilder` parameter of the `AddChatCompletionsClient` method. For example, to set the client ID for this client: + +```csharp +builder.AddChatCompletionsClient("connectionName", configureClientBuilder: configureClientBuilder: builder => builder.ConfigureOptions(options => options.NetworkTimeout = TimeSpan.FromSeconds(2))); +``` + +## Experimental Telemetry + +Azure AI OpenAI telemetry support is experimental, the shape of traces may change in the future without notice. +It can be enabled by invoking + +```c# +AppContext.SetSwitch("Azure.Experimental.EnableActivitySource", true); +``` + +or by setting the "AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE" environment variable to "true". + +## Additional documentation + +* https://learn.microsoft.com/dotnet/api/azure.ai.inference +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire From 137f02f976e06f4044df8f17cd53c387148df968 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Sat, 10 May 2025 10:04:57 +1000 Subject: [PATCH 09/17] Adding tests --- .../Aspire.Azure.AI.Inference.csproj | 7 +++ .../AspireAzureAIInferenceExtensions.cs | 59 ++++++++++++++----- .../AspireAzureAIInferenceExtensionTests.cs | 25 ++++++++ 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj index a754e318375..ce8b4bf9237 100644 --- a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -27,4 +27,11 @@ + + + + \ + true + + diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 44b1d769755..b7c4524a8d8 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -155,24 +155,51 @@ protected override bool GetTracingEnabled(ChatCompletionsClientSettings settings /// /// Creates a from the registered in the service collection. /// - /// + /// An . /// - public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBuilder builder) => - builder.HostBuilder.Services.AddChatClient(services => - { - var chatCompletionsClient = !string.IsNullOrEmpty(builder.ServiceKey) ? - services.GetRequiredService() : - services.GetRequiredKeyedService(builder.ServiceKey); + public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); - var result = chatCompletionsClient.AsIChatClient(); + return builder.HostBuilder.Services.AddChatClient( + services => CreateInnerChatClient(builder, services)); + } - if (builder.DisableTracing) - { - return result; - } + /// + /// Creates a from the registered in the service collection. + /// + /// An . + /// The service key with which the will be registered. + /// + public static ChatClientBuilder AddKeyedChatClient(this AspireChatCompletionsClientBuilder builder, string? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + serviceKey = serviceKey ?? builder.ServiceKey; + + ArgumentException.ThrowIfNullOrEmpty(serviceKey); + + return builder.HostBuilder.Services.AddKeyedChatClient( + serviceKey, + services => CreateInnerChatClient(builder, services)); + } + + private static IChatClient CreateInnerChatClient(AspireChatCompletionsClientBuilder builder, IServiceProvider services) + { + var chatCompletionsClient = string.IsNullOrEmpty(builder.ServiceKey) ? + services.GetRequiredService() : + services.GetRequiredKeyedService(builder.ServiceKey); + + var result = chatCompletionsClient.AsIChatClient(); + + if (builder.DisableTracing) + { + return result; + } + + return new ChatClientBuilder(result) + .UseOpenTelemetry() + .Build(); + } - return new ChatClientBuilder(result) - .UseOpenTelemetry() - .Build(); - }); } diff --git a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs index b5da8df3399..893e8fc23d7 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Azure.AI.Inference; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -84,4 +85,28 @@ public void CanAddMultipleKeyedServices() Assert.NotSame(client1, client2); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanRegisterAsAnIChatClient(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:inference", ConnectionString) + ]); + if (useKeyed) + { + builder.AddKeyedChatCompletionsClient("inference").AddKeyedChatClient("inference"); + } + else + { + builder.AddChatCompletionsClient("inference").AddChatClient(); + } + using var host = builder.Build(); + var client = useKeyed ? + host.Services.GetKeyedService("inference") : + host.Services.GetService(); + Assert.NotNull(client); + } } From 9f9c025ad779104c6adead49f892e88725f581dd Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Sat, 10 May 2025 10:05:53 +1000 Subject: [PATCH 10/17] Adding change from feedback --- .../AspireAzureAIInferenceExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index b7c4524a8d8..7b95fc0f933 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Hosting; @@ -197,9 +198,8 @@ private static IChatClient CreateInnerChatClient(AspireChatCompletionsClientBuil return result; } - return new ChatClientBuilder(result) - .UseOpenTelemetry() - .Build(); + var loggerFactory = services.GetService(); + return new OpenTelemetryChatClient(result, loggerFactory?.CreateLogger(typeof(OpenTelemetryChatClient))); } } From 135733765b219ca655a4672f88837b7cc16c156b Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Sat, 10 May 2025 10:09:46 +1000 Subject: [PATCH 11/17] Adding change from feedback --- .../ChatCompletionsClientSettings.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs index 5806fcf4c9e..29017376e43 100644 --- a/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs +++ b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs @@ -19,7 +19,7 @@ public sealed class ChatCompletionsClientSettings : IConnectionStringSettings /// Gets or sets the connection string used to connect to the AI Foundry account. /// /// - /// If is set, it overrides and . + /// If is set, it overrides , and . /// public string? ConnectionString { get; set; } @@ -55,7 +55,7 @@ public sealed class ChatCompletionsClientSettings : IConnectionStringSettings /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. /// /// - /// ServiceBus ActivitySource support in Azure SDK is experimental, the shape of Activities may change in the future without notice. + /// Azure AI Inference client library ActivitySource support in Azure SDK is experimental, the shape of Activities may change in the future without notice. /// It can be enabled by setting "Azure.Experimental.EnableActivitySource" switch to true. /// Or by setting "AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE" environment variable to "true". /// From c7cf9339ebba548e28579eb0374f16072d6a4c0f Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Sat, 10 May 2025 10:12:09 +1000 Subject: [PATCH 12/17] Renaming the method to include Azure as that's more consistent with other integrations --- .../AspireAzureAIInferenceExtensions.cs | 4 ++-- .../AspireAzureAIInferenceExtensionTests.cs | 16 ++++++++-------- .../AspireAzureAIInferencePublicApiTests.cs | 8 ++++---- .../ConformanceTests.cs | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 7b95fc0f933..005a3c7234b 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -40,7 +40,7 @@ public static class AspireAzureAIInferenceExtensions /// Configuration is loaded from the "Aspire:Azure:AI:Inference" section, and can be supplemented with a connection string named after the parameter. /// /// - public static AspireChatCompletionsClientBuilder AddChatCompletionsClient( + public static AspireChatCompletionsClientBuilder AddAzureChatCompletionsClient( this IHostApplicationBuilder builder, string connectionName, Action? configureClient = null, @@ -77,7 +77,7 @@ public static AspireChatCompletionsClientBuilder AddChatCompletionsClient( /// Configuration is loaded from the "Aspire:Azure:AI:Inference" section, and can be supplemented with a connection string named after the parameter. /// /// - public static AspireChatCompletionsClientBuilder AddKeyedChatCompletionsClient( + public static AspireChatCompletionsClientBuilder AddKeyedAzureChatCompletionsClient( this IHostApplicationBuilder builder, string name, Action? configureClient = null, diff --git a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs index 893e8fc23d7..34dba0fd629 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs @@ -25,11 +25,11 @@ public void ReadsFromConnectionStringsCorrectly(bool useKeyed) ]); if (useKeyed) { - builder.AddKeyedChatCompletionsClient("inference"); + builder.AddKeyedAzureChatCompletionsClient("inference"); } else { - builder.AddChatCompletionsClient("inference"); + builder.AddAzureChatCompletionsClient("inference"); } using var host = builder.Build(); var client = useKeyed ? @@ -51,11 +51,11 @@ public void ConnectionStringCanBeSetInCode(bool useKeyed) if (useKeyed) { - builder.AddKeyedChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); + builder.AddKeyedAzureChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); } else { - builder.AddChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); + builder.AddAzureChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); } using var host = builder.Build(); @@ -75,8 +75,8 @@ public void CanAddMultipleKeyedServices() new KeyValuePair("ConnectionStrings:inference1", ConnectionString), new KeyValuePair("ConnectionStrings:inference2", ConnectionString + "2") ]); - builder.AddKeyedChatCompletionsClient("inference1"); - builder.AddKeyedChatCompletionsClient("inference2"); + builder.AddKeyedAzureChatCompletionsClient("inference1"); + builder.AddKeyedAzureChatCompletionsClient("inference2"); using var host = builder.Build(); var client1 = host.Services.GetKeyedService("inference1"); var client2 = host.Services.GetKeyedService("inference2"); @@ -97,11 +97,11 @@ public void CanRegisterAsAnIChatClient(bool useKeyed) ]); if (useKeyed) { - builder.AddKeyedChatCompletionsClient("inference").AddKeyedChatClient("inference"); + builder.AddKeyedAzureChatCompletionsClient("inference").AddKeyedChatClient("inference"); } else { - builder.AddChatCompletionsClient("inference").AddChatClient(); + builder.AddAzureChatCompletionsClient("inference").AddChatClient(); } using var host = builder.Build(); var client = useKeyed ? diff --git a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs index 4ac283e2b3e..20182dcd7f0 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferencePublicApiTests.cs @@ -14,7 +14,7 @@ public void AddChatCompletionsClientShouldThrowWhenBuilderIsNull() IHostApplicationBuilder builder = null!; const string connectionName = "aiinference"; - var action = () => builder.AddChatCompletionsClient(connectionName); + var action = () => builder.AddAzureChatCompletionsClient(connectionName); var exception = Assert.Throws(action); Assert.Equal(nameof(builder), exception.ParamName); @@ -28,7 +28,7 @@ public void AddChatCompletionsClientShouldThrowWhenConnectionNameIsNullOrEmpty(b var builder = Host.CreateEmptyApplicationBuilder(null); var connectionName = isNull ? null! : string.Empty; - var action = () => builder.AddChatCompletionsClient(connectionName); + var action = () => builder.AddAzureChatCompletionsClient(connectionName); var exception = isNull ? Assert.Throws(action) @@ -42,7 +42,7 @@ public void AddKeyedChatCompletionsClientShouldThrowWhenBuilderIsNull() IHostApplicationBuilder builder = null!; const string name = "aiinference"; - var action = () => builder.AddKeyedChatCompletionsClient(name); + var action = () => builder.AddKeyedAzureChatCompletionsClient(name); var exception = Assert.Throws(action); Assert.Equal(nameof(builder), exception.ParamName); @@ -56,7 +56,7 @@ public void AddKeyedChatCompletionsClientShouldThrowWhenNameIsNullOrEmpty(bool i var builder = Host.CreateEmptyApplicationBuilder(null); var name = isNull ? null! : string.Empty; - var action = () => builder.AddKeyedChatCompletionsClient(name); + var action = () => builder.AddKeyedAzureChatCompletionsClient(name); var exception = isNull ? Assert.Throws(action) diff --git a/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs index 82a25a9c9b7..40330b5cb89 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs +++ b/tests/Aspire.Azure.AI.Inference.Tests/ConformanceTests.cs @@ -49,11 +49,11 @@ protected override void RegisterComponent(HostApplicationBuilder builder, Action { if (key is null) { - builder.AddChatCompletionsClient("inference", ConfigureCredentials); + builder.AddAzureChatCompletionsClient("inference", ConfigureCredentials); } else { - builder.AddChatCompletionsClient(key, ConfigureCredentials); + builder.AddAzureChatCompletionsClient(key, ConfigureCredentials); } void ConfigureCredentials(ChatCompletionsClientSettings settings) From dfbaed1d4f54e8fb3517f12dd216f6a668c75684 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 12 May 2025 10:31:45 -0700 Subject: [PATCH 13/17] Fix unit test --- .../AspireAzureAIInferenceExtensions.cs | 2 +- .../AspireAzureAIInferenceExtensionTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 005a3c7234b..2d754f53531 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -109,7 +109,7 @@ protected override IAzureClientBuilder("ConnectionStrings:inference", "Endpoint=unused;Key=myAccount;DeploymentId=unused") + new KeyValuePair("ConnectionStrings:inference", "Endpoint=https://endpoint;Key=myAccount;DeploymentId=unused") ]); if (useKeyed) From d7e69ded67f8934f1ef405469c97f26d4843c396 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 12 May 2025 10:47:29 -0700 Subject: [PATCH 14/17] Fix README inclusion --- .../Aspire.Azure.AI.Inference.csproj | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj index ce8b4bf9237..1210c82f6ca 100644 --- a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -28,10 +28,4 @@ - - - \ - true - - From 5e2d4078b85240c5e98e8aa86fbb34747606dbbb Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 12 May 2025 15:24:15 -0500 Subject: [PATCH 15/17] Tweak AddChatClient APIs to take an optional DeploymentId. Use DeploymentId when calling AsIChatClient. Make serviceKey required. --- .../AspireAzureAIInferenceExtensions.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 2d754f53531..93bc576be38 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -157,13 +157,14 @@ protected override bool GetTracingEnabled(ChatCompletionsClientSettings settings /// Creates a from the registered in the service collection. /// /// An . + /// Optionally specifies which model deployment to use. If not specified, a value will be taken from the connection string. /// - public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBuilder builder) + public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBuilder builder, string? deploymentId = null) { ArgumentNullException.ThrowIfNull(builder); return builder.HostBuilder.Services.AddChatClient( - services => CreateInnerChatClient(builder, services)); + services => CreateInnerChatClient(builder, services, deploymentId)); } /// @@ -171,27 +172,26 @@ public static ChatClientBuilder AddChatClient(this AspireChatCompletionsClientBu /// /// 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. /// - public static ChatClientBuilder AddKeyedChatClient(this AspireChatCompletionsClientBuilder builder, string? serviceKey) + public static ChatClientBuilder AddKeyedChatClient(this AspireChatCompletionsClientBuilder builder, string serviceKey, string? deploymentId = null) { ArgumentNullException.ThrowIfNull(builder); - serviceKey = serviceKey ?? builder.ServiceKey; - ArgumentException.ThrowIfNullOrEmpty(serviceKey); return builder.HostBuilder.Services.AddKeyedChatClient( serviceKey, - services => CreateInnerChatClient(builder, services)); + services => CreateInnerChatClient(builder, services, deploymentId)); } - private static IChatClient CreateInnerChatClient(AspireChatCompletionsClientBuilder builder, IServiceProvider services) + private static IChatClient CreateInnerChatClient(AspireChatCompletionsClientBuilder builder, IServiceProvider services, string? deploymentId) { var chatCompletionsClient = string.IsNullOrEmpty(builder.ServiceKey) ? services.GetRequiredService() : services.GetRequiredKeyedService(builder.ServiceKey); - var result = chatCompletionsClient.AsIChatClient(); + var result = chatCompletionsClient.AsIChatClient(deploymentId ?? builder.DeploymentId); if (builder.DisableTracing) { @@ -201,5 +201,4 @@ private static IChatClient CreateInnerChatClient(AspireChatCompletionsClientBuil var loggerFactory = services.GetService(); return new OpenTelemetryChatClient(result, loggerFactory?.CreateLogger(typeof(OpenTelemetryChatClient))); } - } From 30fb1ecd91c014f135d808731368a37c0313ca9a Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 12 May 2025 14:45:02 -0700 Subject: [PATCH 16/17] Rename argument --- .../AspireAzureAIInferenceExtensions.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs index 93bc576be38..048172f2906 100644 --- a/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -28,7 +28,7 @@ public static class AspireAzureAIInferenceExtensions /// /// The to add the client to. /// The name of the client. This is used to retrieve the connection string from configuration. - /// An optional callback to configure the . + /// An optional callback to configure the . /// An optional callback to configure the for the client. /// An that can be used to further configure the client. /// Thrown when endpoint is missing from settings. @@ -43,7 +43,7 @@ public static class AspireAzureAIInferenceExtensions public static AspireChatCompletionsClientBuilder AddAzureChatCompletionsClient( this IHostApplicationBuilder builder, string connectionName, - Action? configureClient = null, + Action? configureSettings = null, Action>? configureClientBuilder = null) { ArgumentNullException.ThrowIfNull(builder); @@ -52,7 +52,7 @@ public static AspireChatCompletionsClientBuilder AddAzureChatCompletionsClient( var settings = new ChatCompletionsClientServiceComponent().AddClient( builder, DefaultConfigSectionName, - configureClient, + configureSettings, configureClientBuilder, connectionName, serviceKey: null); @@ -65,7 +65,7 @@ public static AspireChatCompletionsClientBuilder AddAzureChatCompletionsClient( /// /// The to add the client to. /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. - /// An optional callback to configure the . + /// An optional callback to configure the . /// An optional callback to configure the for the client. /// An that can be used to further configure the client. /// Thrown when endpoint is missing from settings. @@ -80,7 +80,7 @@ public static AspireChatCompletionsClientBuilder AddAzureChatCompletionsClient( public static AspireChatCompletionsClientBuilder AddKeyedAzureChatCompletionsClient( this IHostApplicationBuilder builder, string name, - Action? configureClient = null, + Action? configureSettings = null, Action>? configureClientBuilder = null) { ArgumentNullException.ThrowIfNull(builder); @@ -89,7 +89,7 @@ public static AspireChatCompletionsClientBuilder AddKeyedAzureChatCompletionsCli var settings = new ChatCompletionsClientServiceComponent().AddClient( builder, DefaultConfigSectionName, - configureClient, + configureSettings, configureClientBuilder, name, serviceKey: name); From b19df431ba820982e5b03613cb4d238a583f64ca Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 12 May 2025 14:45:16 -0700 Subject: [PATCH 17/17] Add unit test for deploymentId --- .../AspireAzureAIInferenceExtensionTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs index c5bebf4695a..8274d37c3cf 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs @@ -109,4 +109,33 @@ public void CanRegisterAsAnIChatClient(bool useKeyed) host.Services.GetService(); Assert.NotNull(client); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddChatClientUsesCustomDeploymentId(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:inference", ConnectionString) + ]); + if (useKeyed) + { + builder.AddKeyedAzureChatCompletionsClient("inference").AddKeyedChatClient("inference", deploymentId: "other"); + } + else + { + builder.AddAzureChatCompletionsClient("inference").AddChatClient(deploymentId: "other"); + } + + using var host = builder.Build(); + var client = useKeyed ? + host.Services.GetKeyedService("inference") : + host.Services.GetService(); + + var metadata = client?.GetService(); + + Assert.NotNull(metadata); + Assert.Equal("other", metadata?.DefaultModelId); + } }