From 51e68abe7574f2b4672b72e39cbe587f11b80af2 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 1 Jul 2024 18:25:39 +0100 Subject: [PATCH 1/4] feat(azure-ai-sdk-v2): mgrate azure open ai text embeding service to Azure AI SDK v2. --- ...eOpenAIServiceCollectionExtensionsTests.cs | 50 +++- ...enAIServiceKernelBuilderExtensionsTests.cs | 36 +++ ...enAITextEmbeddingGenerationServiceTests.cs | 179 ++++++++++++++ .../Connectors.AzureOpenAI.csproj | 8 - .../AzureOpenAIServiceCollectionExtensions.cs | 226 ++++++++++++++++++ ...ureOpenAITextEmbeddingGenerationService.cs | 4 +- .../AzureOpenAITextEmbeddingTests.cs | 71 ++++++ 7 files changed, 557 insertions(+), 17 deletions(-) create mode 100644 dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs create mode 100644 dotnet/src/IntegrationTestsV2/Connectors/AzureOpenAI/AzureOpenAITextEmbeddingTests.cs diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceCollectionExtensionsTests.cs index 152a968a6bb1..ca4899258b21 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceCollectionExtensionsTests.cs @@ -7,6 +7,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.TextGeneration; namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Extensions; @@ -21,8 +22,8 @@ public sealed class AzureOpenAIServiceCollectionExtensionsTests [Theory] [InlineData(InitializationType.ApiKey)] [InlineData(InitializationType.TokenCredential)] - [InlineData(InitializationType.OpenAIClientInline)] - [InlineData(InitializationType.OpenAIClientInServiceProvider)] + [InlineData(InitializationType.ClientInline)] + [InlineData(InitializationType.ClientInServiceProvider)] public void ServiceCollectionAddAzureOpenAIChatCompletionAddsValidService(InitializationType type) { // Arrange @@ -37,8 +38,8 @@ public void ServiceCollectionAddAzureOpenAIChatCompletionAddsValidService(Initia { InitializationType.ApiKey => builder.Services.AddAzureOpenAIChatCompletion("deployment-name", "https://endpoint", "api-key"), InitializationType.TokenCredential => builder.Services.AddAzureOpenAIChatCompletion("deployment-name", "https://endpoint", credentials), - InitializationType.OpenAIClientInline => builder.Services.AddAzureOpenAIChatCompletion("deployment-name", client), - InitializationType.OpenAIClientInServiceProvider => builder.Services.AddAzureOpenAIChatCompletion("deployment-name"), + InitializationType.ClientInline => builder.Services.AddAzureOpenAIChatCompletion("deployment-name", client), + InitializationType.ClientInServiceProvider => builder.Services.AddAzureOpenAIChatCompletion("deployment-name"), _ => builder.Services }; @@ -52,12 +53,47 @@ public void ServiceCollectionAddAzureOpenAIChatCompletionAddsValidService(Initia #endregion + #region Text embeddings + + [Theory] + [InlineData(InitializationType.ApiKey)] + [InlineData(InitializationType.TokenCredential)] + [InlineData(InitializationType.ClientInline)] + [InlineData(InitializationType.ClientInServiceProvider)] + public void ServiceCollectionAddAzureOpenAITextEmbeddingGenerationAddsValidService(InitializationType type) + { + // Arrange + var credentials = DelegatedTokenCredential.Create((_, _) => new AccessToken()); + var client = new AzureOpenAIClient(new Uri("http://localhost"), "key"); + var builder = Kernel.CreateBuilder(); + + builder.Services.AddSingleton(client); + + // Act + IServiceCollection collection = type switch + { + InitializationType.ApiKey => builder.Services.AddAzureOpenAITextEmbeddingGeneration("deployment-name", "https://endpoint", "api-key"), + InitializationType.TokenCredential => builder.Services.AddAzureOpenAITextEmbeddingGeneration("deployment-name", "https://endpoint", credentials), + InitializationType.ClientInline => builder.Services.AddAzureOpenAITextEmbeddingGeneration("deployment-name", client), + InitializationType.ClientInServiceProvider => builder.Services.AddAzureOpenAITextEmbeddingGeneration("deployment-name"), + _ => builder.Services + }; + + // Assert + var service = builder.Build().GetRequiredService(); + + Assert.NotNull(service); + Assert.True(service is AzureOpenAITextEmbeddingGenerationService); + } + + #endregion + public enum InitializationType { ApiKey, TokenCredential, - OpenAIClientInline, - OpenAIClientInServiceProvider, - OpenAIClientEndpoint, + ClientInline, + ClientInServiceProvider, + ClientEndpoint, } } diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceKernelBuilderExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceKernelBuilderExtensionsTests.cs index 13c5d31ce427..8c5515516ca5 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceKernelBuilderExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Extensions/AzureOpenAIServiceKernelBuilderExtensionsTests.cs @@ -7,6 +7,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.TextGeneration; namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Extensions; @@ -52,6 +53,41 @@ public void KernelBuilderAddAzureOpenAIChatCompletionAddsValidService(Initializa #endregion + #region Text embeddings + + [Theory] + [InlineData(InitializationType.ApiKey)] + [InlineData(InitializationType.TokenCredential)] + [InlineData(InitializationType.OpenAIClientInline)] + [InlineData(InitializationType.OpenAIClientInServiceProvider)] + public void KernelBuilderAddAzureOpenAITextEmbeddingGenerationAddsValidService(InitializationType type) + { + // Arrange + var credentials = DelegatedTokenCredential.Create((_, _) => new AccessToken()); + var client = new AzureOpenAIClient(new Uri("http://localhost"), "key"); + var builder = Kernel.CreateBuilder(); + + builder.Services.AddSingleton(client); + + // Act + builder = type switch + { + InitializationType.ApiKey => builder.AddAzureOpenAITextEmbeddingGeneration("deployment-name", "https://endpoint", "api-key"), + InitializationType.TokenCredential => builder.AddAzureOpenAITextEmbeddingGeneration("deployment-name", "https://endpoint", credentials), + InitializationType.OpenAIClientInline => builder.AddAzureOpenAITextEmbeddingGeneration("deployment-name", client), + InitializationType.OpenAIClientInServiceProvider => builder.AddAzureOpenAITextEmbeddingGeneration("deployment-name"), + _ => builder + }; + + // Assert + var service = builder.Build().GetRequiredService(); + + Assert.NotNull(service); + Assert.True(service is AzureOpenAITextEmbeddingGenerationService); + } + + #endregion + public enum InitializationType { ApiKey, diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs new file mode 100644 index 000000000000..663b8f2319b5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Moq; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Services; + +/// +/// Unit tests for class. +/// +public sealed class AzureOpenAITextEmbeddingGenerationServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Mock _mockLoggerFactory; + + public AzureOpenAITextEmbeddingGenerationServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._messageHandlerStub.ResponseToReturn = this.SuccessfulResponse; + + this._httpClient = new HttpClient(this._messageHandlerStub, false); + this._mockLoggerFactory = new Mock(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItCanBeCreatedWithApiKey(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItCanBeCreatedWithTokenCredential(bool includeLoggerFactory) + { + // Arrange & Act + var credentials = DelegatedTokenCredential.Create((_, _) => new AccessToken()); + + var service = includeLoggerFactory ? + new AzureOpenAITextEmbeddingGenerationService("deployment", "https://endpoint", credentials, "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAITextEmbeddingGenerationService("deployment", "https://endpoint", credentials, "model-id"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItCanBeCreatedWithAzureOpenAIClient(bool includeLoggerFactory) + { + // Arrange & Act + var client = new AzureOpenAIClient(new Uri("http://host"), "key"); + + var service = includeLoggerFactory ? + new AzureOpenAITextEmbeddingGenerationService("deployment", client, "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAITextEmbeddingGenerationService("deployment", client, "model-id"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Fact] + public async Task ItCanReturnEmptyResultWhenGenerateEmbeddingsForEmptyDataAsync() + { + // Arrange + var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient); + + // Act + var result = await service.GenerateEmbeddingsAsync([]); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task ItShouldThrowExceptionWhenGenerateEmbeddingsWithEmptyResponseAsync() + { + // Arrange + var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "object": "list", + "data": [], + "model": "model-id" + } + """, Encoding.UTF8, "application/json") + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GenerateEmbeddingsAsync(["test"])); + Assert.Equal("Expected 1 text embedding(s), but received 0", exception.Message); + } + + [Fact] + public async Task ItGeneratesEmbeddingsAsync() + { + // Arrange + var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient); + + // Act + var result = await service.GenerateEmbeddingsAsync(["test"]); + + // Assert + Assert.Single(result); + + var memory = result[0]; + + Assert.Equal(0.018990106880664825, memory.Span[0]); + Assert.Equal(-0.0073809814639389515, memory.Span[1]); + } + + [Fact] + public async Task ItGeneratesEmbeddingsWithDimensionsWorksAsync() + { + // Arrange + var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient, dimensions: 256); + + // Act + await service.GenerateEmbeddingsAsync(["test"]); + + var requestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + var optionsJson = JsonSerializer.Deserialize(requestContent); + + // Assert + Assert.Equal(256, optionsJson.GetProperty("dimensions").GetInt32()); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + #region private + + private HttpResponseMessage SuccessfulResponse + => new(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "object": "list", + "data": [ + { + "object": "embedding", + "embedding": "JJGbPCnc8bs=", + "index": 0 + } + ], + "model": "model-id" + } + """, Encoding.UTF8, "application/json") + }; + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj b/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj index 29fbd3da46d3..35c31788610d 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj @@ -21,18 +21,10 @@ Semantic Kernel connectors for Azure OpenAI. Contains clients for text generation, chat completion, embedding and DALL-E text to image. - - - - - - - - diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs index 782889c4542c..4b074a6d01ed 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Azure; using Azure.AI.OpenAI; @@ -9,6 +10,7 @@ using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Http; using Microsoft.SemanticKernel.TextGeneration; @@ -241,6 +243,230 @@ public static IServiceCollection AddAzureOpenAIChatCompletion( #endregion + #region Text Embedding + + /// + /// Adds an Azure OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Azure OpenAI API key, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// A local identifier for the given AI service + /// Model identifier, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// The HttpClient to use with this service. + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddAzureOpenAITextEmbeddingGeneration( + this IKernelBuilder builder, + string deploymentName, + string endpoint, + string apiKey, + string? serviceId = null, + string? modelId = null, + HttpClient? httpClient = null, + int? dimensions = null) + { + Verify.NotNull(builder); + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNullOrWhiteSpace(endpoint); + Verify.NotNullOrWhiteSpace(apiKey); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AzureOpenAITextEmbeddingGenerationService( + deploymentName, + endpoint, + apiKey, + modelId, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService(), + dimensions)); + + return builder; + } + + /// + /// Adds an Azure OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Azure OpenAI API key, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// A local identifier for the given AI service + /// Model identifier, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddAzureOpenAITextEmbeddingGeneration( + this IServiceCollection services, + string deploymentName, + string endpoint, + string apiKey, + string? serviceId = null, + string? modelId = null, + int? dimensions = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNullOrWhiteSpace(endpoint); + Verify.NotNullOrWhiteSpace(apiKey); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AzureOpenAITextEmbeddingGenerationService( + deploymentName, + endpoint, + apiKey, + modelId, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService(), + dimensions)); + } + + /// + /// Adds an Azure OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Token credentials, e.g. DefaultAzureCredential, ManagedIdentityCredential, EnvironmentCredential, etc. + /// A local identifier for the given AI service + /// Model identifier, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// The HttpClient to use with this service. + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddAzureOpenAITextEmbeddingGeneration( + this IKernelBuilder builder, + string deploymentName, + string endpoint, + TokenCredential credential, + string? serviceId = null, + string? modelId = null, + HttpClient? httpClient = null, + int? dimensions = null) + { + Verify.NotNull(builder); + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNullOrWhiteSpace(endpoint); + Verify.NotNull(credential); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AzureOpenAITextEmbeddingGenerationService( + deploymentName, + endpoint, + credential, + modelId, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService(), + dimensions)); + + return builder; + } + + /// + /// Adds an Azure OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Token credentials, e.g. DefaultAzureCredential, ManagedIdentityCredential, EnvironmentCredential, etc. + /// A local identifier for the given AI service + /// Model identifier, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddAzureOpenAITextEmbeddingGeneration( + this IServiceCollection services, + string deploymentName, + string endpoint, + TokenCredential credential, + string? serviceId = null, + string? modelId = null, + int? dimensions = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNullOrWhiteSpace(endpoint); + Verify.NotNull(credential); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AzureOpenAITextEmbeddingGenerationService( + deploymentName, + endpoint, + credential, + modelId, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService(), + dimensions)); + } + + /// + /// Adds an Azure OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// to use for the service. If null, one must be available in the service provider when this service is resolved. + /// A local identifier for the given AI service + /// Model identifier, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddAzureOpenAITextEmbeddingGeneration( + this IKernelBuilder builder, + string deploymentName, + AzureOpenAIClient? azureOpenAIClient = null, + string? serviceId = null, + string? modelId = null, + int? dimensions = null) + { + Verify.NotNull(builder); + Verify.NotNullOrWhiteSpace(deploymentName); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AzureOpenAITextEmbeddingGenerationService( + deploymentName, + azureOpenAIClient ?? serviceProvider.GetRequiredService(), + modelId, + serviceProvider.GetService(), + dimensions)); + + return builder; + } + + /// + /// Adds an Azure OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// to use for the service. If null, one must be available in the service provider when this service is resolved. + /// A local identifier for the given AI service + /// Model identifier, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddAzureOpenAITextEmbeddingGeneration( + this IServiceCollection services, + string deploymentName, + AzureOpenAIClient? azureOpenAIClient = null, + string? serviceId = null, + string? modelId = null, + int? dimensions = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(deploymentName); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AzureOpenAITextEmbeddingGenerationService( + deploymentName, + azureOpenAIClient ?? serviceProvider.GetRequiredService(), + modelId, + serviceProvider.GetService(), + dimensions)); + } + + #endregion + private static AzureOpenAIClient CreateAzureOpenAIClient(string endpoint, AzureKeyCredential credentials, HttpClient? httpClient) => new(new Uri(endpoint), credentials, ClientCore.GetOpenAIClientOptions(httpClient)); diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Services/AzureOpenAITextEmbeddingGenerationService.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Services/AzureOpenAITextEmbeddingGenerationService.cs index 9119a9005939..31159da6f0a5 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Services/AzureOpenAITextEmbeddingGenerationService.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Services/AzureOpenAITextEmbeddingGenerationService.cs @@ -79,13 +79,13 @@ public AzureOpenAITextEmbeddingGenerationService( /// Creates a new client. /// /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource - /// Custom for HTTP requests. + /// Custom for HTTP requests. /// Azure OpenAI model id, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource /// The to use for logging. If null, no logging will be performed. /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. public AzureOpenAITextEmbeddingGenerationService( string deploymentName, - OpenAIClient openAIClient, + AzureOpenAIClient openAIClient, string? modelId = null, ILoggerFactory? loggerFactory = null, int? dimensions = null) diff --git a/dotnet/src/IntegrationTestsV2/Connectors/AzureOpenAI/AzureOpenAITextEmbeddingTests.cs b/dotnet/src/IntegrationTestsV2/Connectors/AzureOpenAI/AzureOpenAITextEmbeddingTests.cs new file mode 100644 index 000000000000..1dfc39670416 --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/Connectors/AzureOpenAI/AzureOpenAITextEmbeddingTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Embeddings; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTestsV2.Connectors.AzureOpenAI; + +public sealed class AzureOpenAITextEmbeddingTests +{ + public AzureOpenAITextEmbeddingTests() + { + var config = this._configuration.GetSection("AzureOpenAIEmbeddings").Get(); + Assert.NotNull(config); + this._azureOpenAIConfiguration = config; + } + + [Theory] + [InlineData("test sentence")] + public async Task AzureOpenAITestAsync(string testInputString) + { + // Arrange + var embeddingGenerator = new AzureOpenAITextEmbeddingGenerationService( + this._azureOpenAIConfiguration.DeploymentName, + this._azureOpenAIConfiguration.Endpoint, + this._azureOpenAIConfiguration.ApiKey); + + // Act + var singleResult = await embeddingGenerator.GenerateEmbeddingAsync(testInputString); + var batchResult = await embeddingGenerator.GenerateEmbeddingsAsync([testInputString, testInputString, testInputString]); + + // Assert + Assert.Equal(AdaVectorLength, singleResult.Length); + Assert.Equal(3, batchResult.Count); + } + + [Theory] + [InlineData(null, 3072)] + [InlineData(1024, 1024)] + public async Task AzureOpenAIWithDimensionsAsync(int? dimensions, int expectedVectorLength) + { + // Arrange + const string TestInputString = "test sentence"; + + var embeddingGenerator = new AzureOpenAITextEmbeddingGenerationService( + "text-embedding-3-large", + this._azureOpenAIConfiguration.Endpoint, + this._azureOpenAIConfiguration.ApiKey, + dimensions: dimensions); + + // Act + var result = await embeddingGenerator.GenerateEmbeddingAsync(TestInputString); + + // Assert + Assert.Equal(expectedVectorLength, result.Length); + } + + private readonly AzureOpenAIConfiguration _azureOpenAIConfiguration; + + private const int AdaVectorLength = 1536; + + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); +} From b48c2f2610a6fbfbefb3c09a7e9031236dece10f Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 1 Jul 2024 20:36:56 +0100 Subject: [PATCH 2/4] fix: addressing PR review comments --- .../Connectors.AzureOpenAI.UnitTests.csproj | 4 +- ...enAITextEmbeddingGenerationServiceTests.cs | 161 ++++-------------- .../text-embeddings-multiple-response.txt | 20 +++ .../TestData/text-embeddings-response.txt | 15 ++ 4 files changed, 73 insertions(+), 127 deletions(-) create mode 100644 dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-multiple-response.txt create mode 100644 dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-response.txt diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj index 5952d571a09f..a0a695a6719c 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj @@ -8,7 +8,7 @@ true enable false - $(NoWarn);SKEXP0001;SKEXP0010;CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111 + $(NoWarn);SKEXP0001;SKEXP0010;CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,IDE1006 @@ -27,7 +27,7 @@ - + diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs index 663b8f2319b5..738364429cff 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextEmbeddingGenerationServiceTests.cs @@ -1,179 +1,90 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.ClientModel; +using System.IO; +using System.Net; using System.Net.Http; -using System.Text; -using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Azure.AI.OpenAI; -using Azure.Core; -using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; -using Moq; +using Microsoft.SemanticKernel.Services; namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Services; /// /// Unit tests for class. /// -public sealed class AzureOpenAITextEmbeddingGenerationServiceTests : IDisposable +public class AzureOpenAITextEmbeddingGenerationServiceTests { - private readonly HttpMessageHandlerStub _messageHandlerStub; - private readonly HttpClient _httpClient; - private readonly Mock _mockLoggerFactory; - - public AzureOpenAITextEmbeddingGenerationServiceTests() - { - this._messageHandlerStub = new HttpMessageHandlerStub(); - this._messageHandlerStub.ResponseToReturn = this.SuccessfulResponse; - - this._httpClient = new HttpClient(this._messageHandlerStub, false); - this._mockLoggerFactory = new Mock(); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void ItCanBeCreatedWithApiKey(bool includeLoggerFactory) - { - // Arrange & Act - var service = includeLoggerFactory ? - new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", loggerFactory: this._mockLoggerFactory.Object) : - new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id"); - - // Assert - Assert.NotNull(service); - Assert.Equal("model-id", service.Attributes["ModelId"]); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void ItCanBeCreatedWithTokenCredential(bool includeLoggerFactory) - { - // Arrange & Act - var credentials = DelegatedTokenCredential.Create((_, _) => new AccessToken()); - - var service = includeLoggerFactory ? - new AzureOpenAITextEmbeddingGenerationService("deployment", "https://endpoint", credentials, "model-id", loggerFactory: this._mockLoggerFactory.Object) : - new AzureOpenAITextEmbeddingGenerationService("deployment", "https://endpoint", credentials, "model-id"); - - // Assert - Assert.NotNull(service); - Assert.Equal("model-id", service.Attributes["ModelId"]); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void ItCanBeCreatedWithAzureOpenAIClient(bool includeLoggerFactory) + [Fact] + public void ItCanBeInstantiatedAndPropertiesSetAsExpected() { - // Arrange & Act - var client = new AzureOpenAIClient(new Uri("http://host"), "key"); - - var service = includeLoggerFactory ? - new AzureOpenAITextEmbeddingGenerationService("deployment", client, "model-id", loggerFactory: this._mockLoggerFactory.Object) : - new AzureOpenAITextEmbeddingGenerationService("deployment", client, "model-id"); + // Arrange + var sut = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", modelId: "model", dimensions: 2); + var sutWithAzureOpenAIClient = new AzureOpenAITextEmbeddingGenerationService("deployment-name", new AzureOpenAIClient(new Uri("https://endpoint"), new ApiKeyCredential("apiKey")), modelId: "model", dimensions: 2); // Assert - Assert.NotNull(service); - Assert.Equal("model-id", service.Attributes["ModelId"]); + Assert.NotNull(sut); + Assert.NotNull(sutWithAzureOpenAIClient); + Assert.Equal("model", sut.Attributes[AIServiceExtensions.ModelIdKey]); + Assert.Equal("model", sutWithAzureOpenAIClient.Attributes[AIServiceExtensions.ModelIdKey]); } [Fact] - public async Task ItCanReturnEmptyResultWhenGenerateEmbeddingsForEmptyDataAsync() + public async Task ItGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsEmpty() { // Arrange - var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient); + var sut = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key"); // Act - var result = await service.GenerateEmbeddingsAsync([]); + var result = await sut.GenerateEmbeddingsAsync([], null, CancellationToken.None); // Assert Assert.Empty(result); } [Fact] - public async Task ItShouldThrowExceptionWhenGenerateEmbeddingsWithEmptyResponseAsync() + public async Task GetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsWhitespace() { // Arrange - var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient); - this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + using HttpMessageHandlerStub handler = new() { - Content = new StringContent(""" + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) { - "object": "list", - "data": [], - "model": "model-id" + Content = new StringContent(File.ReadAllText("./TestData/text-embeddings-response.txt")) } - """, Encoding.UTF8, "application/json") }; + using HttpClient client = new(handler); - // Act & Assert - var exception = await Assert.ThrowsAsync(() => service.GenerateEmbeddingsAsync(["test"])); - Assert.Equal("Expected 1 text embedding(s), but received 0", exception.Message); - } - - [Fact] - public async Task ItGeneratesEmbeddingsAsync() - { - // Arrange - var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient); + var sut = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", httpClient: client); // Act - var result = await service.GenerateEmbeddingsAsync(["test"]); + var result = await sut.GenerateEmbeddingsAsync(["test"], null, CancellationToken.None); // Assert Assert.Single(result); - - var memory = result[0]; - - Assert.Equal(0.018990106880664825, memory.Span[0]); - Assert.Equal(-0.0073809814639389515, memory.Span[1]); + Assert.Equal(4, result[0].Length); } [Fact] - public async Task ItGeneratesEmbeddingsWithDimensionsWorksAsync() + public async Task ItThrowsIfNumberOfResultsDiffersFromInputsAsync() { // Arrange - var service = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient, dimensions: 256); - - // Act - await service.GenerateEmbeddingsAsync(["test"]); - - var requestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); - var optionsJson = JsonSerializer.Deserialize(requestContent); - - // Assert - Assert.Equal(256, optionsJson.GetProperty("dimensions").GetInt32()); - } - - public void Dispose() - { - this._httpClient.Dispose(); - this._messageHandlerStub.Dispose(); - } - - #region private - - private HttpResponseMessage SuccessfulResponse - => new(System.Net.HttpStatusCode.OK) + using HttpMessageHandlerStub handler = new() { - Content = new StringContent(""" + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) { - "object": "list", - "data": [ - { - "object": "embedding", - "embedding": "JJGbPCnc8bs=", - "index": 0 - } - ], - "model": "model-id" + Content = new StringContent(File.ReadAllText("./TestData/text-embeddings-multiple-response.txt")) } - """, Encoding.UTF8, "application/json") }; + using HttpClient client = new(handler); + + var sut = new AzureOpenAITextEmbeddingGenerationService("deployment-name", "https://endpoint", "api-key", httpClient: client); - #endregion + // Act & Assert + await Assert.ThrowsAsync(async () => await sut.GenerateEmbeddingsAsync(["test"], null, CancellationToken.None)); + } } diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-multiple-response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-multiple-response.txt new file mode 100644 index 000000000000..46a9581cf0cc --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-multiple-response.txt @@ -0,0 +1,20 @@ +{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "zcyMP83MDEAzM1NAzcyMQA==" + }, + { + "object": "embedding", + "index": 1, + "embedding": "zcyMP83MDEAzM1NAzcyMQA==" + } + ], + "model": "text-embedding-ada-002", + "usage": { + "prompt_tokens": 7, + "total_tokens": 7 + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-response.txt new file mode 100644 index 000000000000..c715b851b78c --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text-embeddings-response.txt @@ -0,0 +1,15 @@ +{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "zcyMP83MDEAzM1NAzcyMQA==" + } + ], + "model": "text-embedding-ada-002", + "usage": { + "prompt_tokens": 7, + "total_tokens": 7 + } +} From 1725b6e9f4b5d6751eedf866a1e685fdfc05a4a0 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 2 Jul 2024 09:32:25 +0100 Subject: [PATCH 3/4] fix: remove duplicating parameter checks. --- .../AzureOpenAIServiceCollectionExtensions.cs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs index 98adc36a4053..e25eac02789b 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Extensions/AzureOpenAIServiceCollectionExtensions.cs @@ -46,7 +46,6 @@ public static IKernelBuilder AddAzureOpenAIChatCompletion( HttpClient? httpClient = null) { Verify.NotNull(builder); - Verify.NotNullOrWhiteSpace(deploymentName); Verify.NotNullOrWhiteSpace(endpoint); Verify.NotNullOrWhiteSpace(apiKey); @@ -85,7 +84,6 @@ public static IServiceCollection AddAzureOpenAIChatCompletion( string? modelId = null) { Verify.NotNull(services); - Verify.NotNullOrWhiteSpace(deploymentName); Verify.NotNullOrWhiteSpace(endpoint); Verify.NotNullOrWhiteSpace(apiKey); @@ -126,7 +124,6 @@ public static IKernelBuilder AddAzureOpenAIChatCompletion( HttpClient? httpClient = null) { Verify.NotNull(builder); - Verify.NotNullOrWhiteSpace(deploymentName); Verify.NotNullOrWhiteSpace(endpoint); Verify.NotNull(credentials); @@ -165,7 +162,6 @@ public static IServiceCollection AddAzureOpenAIChatCompletion( string? modelId = null) { Verify.NotNull(services); - Verify.NotNullOrWhiteSpace(deploymentName); Verify.NotNullOrWhiteSpace(endpoint); Verify.NotNull(credentials); @@ -269,9 +265,6 @@ public static IKernelBuilder AddAzureOpenAITextEmbeddingGeneration( int? dimensions = null) { Verify.NotNull(builder); - Verify.NotNullOrWhiteSpace(deploymentName); - Verify.NotNullOrWhiteSpace(endpoint); - Verify.NotNullOrWhiteSpace(apiKey); builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AzureOpenAITextEmbeddingGenerationService( @@ -308,9 +301,6 @@ public static IServiceCollection AddAzureOpenAITextEmbeddingGeneration( int? dimensions = null) { Verify.NotNull(services); - Verify.NotNullOrWhiteSpace(deploymentName); - Verify.NotNullOrWhiteSpace(endpoint); - Verify.NotNullOrWhiteSpace(apiKey); return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AzureOpenAITextEmbeddingGenerationService( @@ -347,8 +337,6 @@ public static IKernelBuilder AddAzureOpenAITextEmbeddingGeneration( int? dimensions = null) { Verify.NotNull(builder); - Verify.NotNullOrWhiteSpace(deploymentName); - Verify.NotNullOrWhiteSpace(endpoint); Verify.NotNull(credential); builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => @@ -386,8 +374,6 @@ public static IServiceCollection AddAzureOpenAITextEmbeddingGeneration( int? dimensions = null) { Verify.NotNull(services); - Verify.NotNullOrWhiteSpace(deploymentName); - Verify.NotNullOrWhiteSpace(endpoint); Verify.NotNull(credential); return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => @@ -421,7 +407,6 @@ public static IKernelBuilder AddAzureOpenAITextEmbeddingGeneration( int? dimensions = null) { Verify.NotNull(builder); - Verify.NotNullOrWhiteSpace(deploymentName); builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AzureOpenAITextEmbeddingGenerationService( @@ -454,7 +439,6 @@ public static IServiceCollection AddAzureOpenAITextEmbeddingGeneration( int? dimensions = null) { Verify.NotNull(services); - Verify.NotNullOrWhiteSpace(deploymentName); return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AzureOpenAITextEmbeddingGenerationService( From 6a329356f3530ba8aa1a547b61c4be41e229c8a0 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 2 Jul 2024 09:46:26 +0100 Subject: [PATCH 4/4] fix: fix compiler warning that says Xunit namespace is unnecessary. --- dotnet/src/InternalUtilities/test/AssertExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/InternalUtilities/test/AssertExtensions.cs b/dotnet/src/InternalUtilities/test/AssertExtensions.cs index cf201d169366..4caf63589cbc 100644 --- a/dotnet/src/InternalUtilities/test/AssertExtensions.cs +++ b/dotnet/src/InternalUtilities/test/AssertExtensions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Xunit; +using Assert = Xunit.Assert; namespace SemanticKernel.UnitTests;