diff --git a/Aspire.sln b/Aspire.sln index d81f37f087a..8a8bbd95f41 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -662,6 +662,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Yarp", "src\ 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 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.AppService", "src\Aspire.Hosting.Azure.AppService\Aspire.Hosting.Azure.AppService.csproj", "{5DDF8E89-FBBD-4A6F-BF32-7D2140724941}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureAppService", "AzureAppService", "{2D9974C2-3AB2-FBFD-5156-080508BB7449}" @@ -670,7 +672,10 @@ 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 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Yarp.Tests", "tests\Aspire.Hosting.Yarp.Tests\Aspire.Hosting.Yarp.Tests.csproj", "{2652012B-C28B-461B-93F6-9D1C8247EAB7}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SqlServerScript", "SqlServerScript", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost1", "playground\SqlServerScript\AppHost1\AppHost1.csproj", "{3928CF69-B803-43A2-8AE5-5E29CB3E8D24}" @@ -3907,6 +3912,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 {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|Any CPU.Build.0 = Debug|Any CPU {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -3943,6 +3960,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 {2652012B-C28B-461B-93F6-9D1C8247EAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2652012B-C28B-461B-93F6-9D1C8247EAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU {2652012B-C28B-461B-93F6-9D1C8247EAB7}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -4321,10 +4350,12 @@ Global {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7} = {C424395C-1235-41A4-BF55-07880A04368C} {A3399DE9-AAB0-43EA-B99B-6A62ABBDD7BF} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} {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} {2652012B-C28B-461B-93F6-9D1C8247EAB7} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {3928CF69-B803-43A2-8AE5-5E29CB3E8D24} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} diff --git a/Directory.Packages.props b/Directory.Packages.props index 08ea0b6de79..d5c682b0938 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + @@ -156,6 +157,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..1210c82f6ca --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -0,0 +1,31 @@ + + + + $(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..048172f2906 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/AspireAzureAIInferenceExtensions.cs @@ -0,0 +1,204 @@ +// 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 AddAzureChatCompletionsClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + var settings = new ChatCompletionsClientServiceComponent().AddClient( + builder, + DefaultConfigSectionName, + configureSettings, + configureClientBuilder, + connectionName, + serviceKey: null); + + return new AspireChatCompletionsClientBuilder(builder, serviceKey: null, settings.DeploymentId, 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 AddKeyedAzureChatCompletionsClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var settings = new ChatCompletionsClientServiceComponent().AddClient( + builder, + DefaultConfigSectionName, + configureSettings, + configureClientBuilder, + name, + serviceKey: name); + + return new AspireChatCompletionsClientBuilder(builder, serviceKey: name, settings.DeploymentId, 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($"A ChatCompletionsClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a '{nameof(ChatCompletionsClientSettings.Endpoint)}' and optionally a '{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. + /// + /// 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, string? deploymentId = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.HostBuilder.Services.AddChatClient( + services => CreateInnerChatClient(builder, services, deploymentId)); + } + + /// + /// Creates a from the registered in the service collection. + /// + /// 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, string? deploymentId = null) + { + ArgumentNullException.ThrowIfNull(builder); + + ArgumentException.ThrowIfNullOrEmpty(serviceKey); + + return builder.HostBuilder.Services.AddKeyedChatClient( + serviceKey, + services => CreateInnerChatClient(builder, services, deploymentId)); + } + + 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(deploymentId ?? builder.DeploymentId); + + 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..1d9a836cd96 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/AspireChatCompletionsClientBuilder.cs @@ -0,0 +1,43 @@ +// 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 id of the deployment in Azure AI Foundry. +/// A flag to indicate whether tracing should be disabled. +public class AspireChatCompletionsClientBuilder( + IHostApplicationBuilder hostBuilder, + string? serviceKey, + string? deploymentId, + bool disableTracing) +{ + /// + /// 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; +} 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 new file mode 100644 index 00000000000..29017376e43 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/ChatCompletionsClientSettings.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; +using Aspire.Azure.Common; +using Azure.Core; +using Microsoft.Identity.Client.Platforms.Features.DesktopOs.Kerberos; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Represents configuration settings for Azure AI Chat Completions client. +/// +public sealed class ChatCompletionsClientSettings : IConnectionStringSettings +{ + private bool? _disableTracing; + + /// + /// 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. + /// + public string? DeploymentId { 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 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". + /// + /// + /// 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. + /// + /// The connection string containing configuration values. + /// + /// The connection string can contain the following keys: + /// - DeploymentId: The ID of the AI model + /// - Endpoint: The service endpoint URI + /// - Key: The API key for authentication + /// + void IConnectionStringSettings.ParseConnectionString(string? connectionString) + { + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + if (connectionBuilder.TryGetValue(nameof(DeploymentId), out var modelId)) + { + DeploymentId = 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(); + } + } +} 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..21572287031 --- /dev/null +++ b/src/Components/Aspire.Azure.AI.Inference/ConfigurationSchema.json @@ -0,0 +1,153 @@ +{ + "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." + }, + "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." + }, + "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.", + "default": false + }, + "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." + } + } + } + } + } + } + } + } +} 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 diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 417820ff8c8..634599595fd 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -11,6 +11,7 @@ These integrations should follow the [.NET Aspire Integration Requirements](#net | 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: 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..8274d37c3cf --- /dev/null +++ b/tests/Aspire.Azure.AI.Inference.Tests/AspireAzureAIInferenceExtensionTests.cs @@ -0,0 +1,141 @@ +// 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.AI; +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.AddKeyedAzureChatCompletionsClient("inference"); + } + else + { + builder.AddAzureChatCompletionsClient("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=https://endpoint;Key=myAccount;DeploymentId=unused") + ]); + + if (useKeyed) + { + builder.AddKeyedAzureChatCompletionsClient("inference", settings => settings.ConnectionString = ConnectionString); + } + else + { + builder.AddAzureChatCompletionsClient("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.AddKeyedAzureChatCompletionsClient("inference1"); + builder.AddKeyedAzureChatCompletionsClient("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); + } + + [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.AddKeyedAzureChatCompletionsClient("inference").AddKeyedChatClient("inference"); + } + else + { + builder.AddAzureChatCompletionsClient("inference").AddChatClient(); + } + 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 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); + } +} 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..20182dcd7f0 --- /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.AddAzureChatCompletionsClient(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.AddAzureChatCompletionsClient(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.AddKeyedAzureChatCompletionsClient(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.AddKeyedAzureChatCompletionsClient(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..40330b5cb89 --- /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.AddAzureChatCompletionsClient("inference", ConfigureCredentials); + } + else + { + builder.AddAzureChatCompletionsClient(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")] }); + } +}