diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index 01ffff52057a..326a35a79ff7 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -92,6 +92,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5C246969-D ProjectSection(SolutionItems) = preProject src\InternalUtilities\test\AssertExtensions.cs = src\InternalUtilities\test\AssertExtensions.cs src\InternalUtilities\test\HttpMessageHandlerStub.cs = src\InternalUtilities\test\HttpMessageHandlerStub.cs + src\Connectors\Connectors.OpenAIV2.UnitTests\Utils\MoqExtensions.cs = src\Connectors\Connectors.OpenAIV2.UnitTests\Utils\MoqExtensions.cs src\InternalUtilities\test\MultipleHttpMessageHandlerStub.cs = src\InternalUtilities\test\MultipleHttpMessageHandlerStub.cs src\InternalUtilities\test\TestInternalUtilities.props = src\InternalUtilities\test\TestInternalUtilities.props EndProjectSection @@ -324,7 +325,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestsV2", "src\I EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connectors.AzureOpenAI", "src\Connectors\Connectors.AzureOpenAI\Connectors.AzureOpenAI.csproj", "{6744272E-8326-48CE-9A3F-6BE227A5E777}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Connectors.AzureOpenAI.UnitTests", "src\Connectors\Connectors.AzureOpenAI.UnitTests\Connectors.AzureOpenAI.UnitTests.csproj", "{DB219924-208B-4CDD-8796-EE424689901E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connectors.AzureOpenAI.UnitTests", "src\Connectors\Connectors.AzureOpenAI.UnitTests\Connectors.AzureOpenAI.UnitTests.csproj", "{DB219924-208B-4CDD-8796-EE424689901E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj index 0d89e02beb21..0a100b3c13a6 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj @@ -28,14 +28,17 @@ - - + + + + + @@ -44,6 +47,9 @@ Always + + Always + diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs index a3415663459a..f162e1d7334c 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.Services; using Moq; using OpenAI; using Xunit; @@ -23,7 +24,7 @@ public void ItCanBeInstantiatedAndPropertiesSetAsExpected() { // Act var logger = new Mock>().Object; - var openAIClient = new OpenAIClient(new ApiKeyCredential("key")); + var openAIClient = new OpenAIClient("key"); var clientCoreModelConstructor = new ClientCore("model1", "apiKey"); var clientCoreOpenAIClientConstructor = new ClientCore("model1", openAIClient, logger: logger); @@ -67,6 +68,8 @@ public void ItUsesEndpointAsExpected(string? clientBaseAddress, string? provided // Assert Assert.Equal(endpoint ?? client?.BaseAddress ?? new Uri("https://api.openai.com/v1"), clientCore.Endpoint); + Assert.True(clientCore.Attributes.ContainsKey(AIServiceExtensions.EndpointKey)); + Assert.Equal(endpoint?.ToString() ?? client?.BaseAddress?.ToString() ?? "https://api.openai.com/v1", clientCore.Attributes[AIServiceExtensions.EndpointKey]); client?.Dispose(); } @@ -142,7 +145,7 @@ public async Task ItDoNotAddSemanticKernelHeadersWhenOpenAIClientIsProvidedAsync var clientCore = new ClientCore( modelId: "model", openAIClient: new OpenAIClient( - new ApiKeyCredential("test"), + "test", new OpenAIClientOptions() { Transport = new HttpClientPipelineTransport(client), @@ -185,4 +188,65 @@ public void ItAddAttributesButDoesNothingIfNullOrEmpty(string? value) Assert.Equal(value, clientCore.Attributes["key"]); } } + + [Fact] + public void ItAddModelIdAttributeAsExpected() + { + // Arrange + var expectedModelId = "modelId"; + + // Act + var clientCore = new ClientCore(expectedModelId, "apikey"); + var clientCoreBreakingGlass = new ClientCore(expectedModelId, new OpenAIClient(" ")); + + // Assert + Assert.True(clientCore.Attributes.ContainsKey(AIServiceExtensions.ModelIdKey)); + Assert.True(clientCoreBreakingGlass.Attributes.ContainsKey(AIServiceExtensions.ModelIdKey)); + Assert.Equal(expectedModelId, clientCore.Attributes[AIServiceExtensions.ModelIdKey]); + Assert.Equal(expectedModelId, clientCoreBreakingGlass.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItAddOrNotOrganizationIdAttributeWhenProvided() + { + // Arrange + var expectedOrganizationId = "organizationId"; + + // Act + var clientCore = new ClientCore("modelId", "apikey", expectedOrganizationId); + var clientCoreWithoutOrgId = new ClientCore("modelId", "apikey"); + + // Assert + Assert.True(clientCore.Attributes.ContainsKey(ClientCore.OrganizationKey)); + Assert.Equal(expectedOrganizationId, clientCore.Attributes[ClientCore.OrganizationKey]); + Assert.False(clientCoreWithoutOrgId.Attributes.ContainsKey(ClientCore.OrganizationKey)); + } + + [Fact] + public void ItThrowsIfModelIdIsNotProvided() + { + // Act & Assert + Assert.Throws(() => new ClientCore(" ", "apikey")); + Assert.Throws(() => new ClientCore("", "apikey")); + Assert.Throws(() => new ClientCore(null!)); + } + + [Fact] + public void ItThrowsWhenNotUsingCustomEndpointAndApiKeyIsNotProvided() + { + // Act & Assert + Assert.Throws(() => new ClientCore("modelId", " ")); + Assert.Throws(() => new ClientCore("modelId", "")); + Assert.Throws(() => new ClientCore("modelId", apiKey: null!)); + } + + [Fact] + public void ItDoesNotThrowWhenUsingCustomEndpointAndApiKeyIsNotProvided() + { + // Act & Assert + ClientCore? clientCore = null; + clientCore = new ClientCore("modelId", " ", endpoint: new Uri("http://localhost")); + clientCore = new ClientCore("modelId", "", endpoint: new Uri("http://localhost")); + clientCore = new ClientCore("modelId", apiKey: null!, endpoint: new Uri("http://localhost")); + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs new file mode 100644 index 000000000000..f296000c5245 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Services; +using Microsoft.SemanticKernel.TextToImage; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; + +public class KernelBuilderExtensionsTests +{ + [Fact] + public void ItCanAddTextEmbeddingGenerationService() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAITextEmbeddingGeneration("model", "key") + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddTextEmbeddingGenerationServiceWithOpenAIClient() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAITextEmbeddingGeneration("model", new OpenAIClient("key")) + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddTextToImageService() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAITextToImage("model", "key") + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddTextToImageServiceWithOpenAIClient() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAITextToImage("model", new OpenAIClient("key")) + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000000..65db68eea180 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Services; +using Microsoft.SemanticKernel.TextToImage; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void ItCanAddTextEmbeddingGenerationService() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAITextEmbeddingGeneration("model", "key") + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddTextEmbeddingGenerationServiceWithOpenAIClient() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAITextEmbeddingGeneration("model", new OpenAIClient("key")) + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddImageToTextService() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAITextToImage("model", "key") + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddImageToTextServiceWithOpenAIClient() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAITextToImage("model", new OpenAIClient("key")) + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs index 25cdc4ec61aa..5fb36efc0349 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs @@ -6,13 +6,19 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Services; +using Moq; using OpenAI; using Xunit; namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Unit tests for class. +/// public class OpenAITextEmbeddingGenerationServiceTests { [Fact] @@ -43,8 +49,9 @@ public async Task ItGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsEmpty() } [Fact] - public async Task IGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsWhitespace() + public async Task GetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsWhitespace() { + // Arrange using HttpMessageHandlerStub handler = new() { ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) @@ -54,7 +61,6 @@ public async Task IGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsWhitespace() }; using HttpClient client = new(handler); - // Arrange var sut = new OpenAITextEmbeddingGenerationService("model", "apikey", httpClient: client); // Act @@ -68,6 +74,7 @@ public async Task IGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsWhitespace() [Fact] public async Task ItThrowsIfNumberOfResultsDiffersFromInputsAsync() { + // Arrange using HttpMessageHandlerStub handler = new() { ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) @@ -77,10 +84,38 @@ public async Task ItThrowsIfNumberOfResultsDiffersFromInputsAsync() }; using HttpClient client = new(handler); - // Arrange var sut = new OpenAITextEmbeddingGenerationService("model", "apikey", httpClient: client); // Act & Assert await Assert.ThrowsAsync(async () => await sut.GenerateEmbeddingsAsync(["test"], null, CancellationToken.None)); } + + [Fact] + public async Task GetEmbeddingsDoesLogActionAsync() + { + // Arrange + using HttpMessageHandlerStub handler = new() + { + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("./TestData/text-embeddings-response.txt")) + } + }; + using HttpClient client = new(handler); + + var modelId = "dall-e-2"; + var logger = new Mock>(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(logger.Object); + + var sut = new OpenAITextEmbeddingGenerationService(modelId, "apiKey", httpClient: client, loggerFactory: mockLoggerFactory.Object); + + // Act + await sut.GenerateEmbeddingsAsync(["description"]); + + // Assert + logger.VerifyLog(LogLevel.Information, $"Action: {nameof(OpenAITextEmbeddingGenerationService.GenerateEmbeddingsAsync)}. OpenAI Model ID: {modelId}.", Times.Once()); + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs new file mode 100644 index 000000000000..919b864327e8 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.Services; +using Moq; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.UnitTests.OpenAI.Services; + +/// +/// Unit tests for class. +/// +public sealed class OpenAITextToImageServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Mock _mockLoggerFactory; + + public OpenAITextToImageServiceTests() + { + this._messageHandlerStub = new() + { + ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("./TestData/text-to-image-response.txt")) + } + }; + this._httpClient = new HttpClient(this._messageHandlerStub, false); + this._mockLoggerFactory = new Mock(); + } + + [Fact] + public void ConstructorWorksCorrectly() + { + // Arrange & Act + var sut = new OpenAITextToImageService("model", "api-key", "organization"); + + // Assert + Assert.NotNull(sut); + Assert.Equal("organization", sut.Attributes[ClientCore.OrganizationKey]); + Assert.Equal("model", sut.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void OpenAIClientConstructorWorksCorrectly() + { + // Arrange + var sut = new OpenAITextToImageService("model", new OpenAIClient("apikey")); + + // Assert + Assert.NotNull(sut); + Assert.Equal("model", sut.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Theory] + [InlineData(256, 256, "dall-e-2")] + [InlineData(512, 512, "dall-e-2")] + [InlineData(1024, 1024, "dall-e-2")] + [InlineData(1024, 1024, "dall-e-3")] + [InlineData(1024, 1792, "dall-e-3")] + [InlineData(1792, 1024, "dall-e-3")] + [InlineData(123, 321, "custom-model-1")] + [InlineData(179, 124, "custom-model-2")] + public async Task GenerateImageWorksCorrectlyAsync(int width, int height, string modelId) + { + // Arrange + var sut = new OpenAITextToImageService(modelId, "api-key", httpClient: this._httpClient); + Assert.Equal(modelId, sut.Attributes["ModelId"]); + + // Act + var result = await sut.GenerateImageAsync("description", width, height); + + // Assert + Assert.Equal("https://image-url/", result); + } + + [Fact] + public async Task GenerateImageDoesLogActionAsync() + { + // Assert + var modelId = "dall-e-2"; + var logger = new Mock>(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + this._mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(logger.Object); + + // Arrange + var sut = new OpenAITextToImageService(modelId, "apiKey", httpClient: this._httpClient, loggerFactory: this._mockLoggerFactory.Object); + + // Act + await sut.GenerateImageAsync("description", 256, 256); + + // Assert + logger.VerifyLog(LogLevel.Information, $"Action: {nameof(OpenAITextToImageService.GenerateImageAsync)}. OpenAI Model ID: {modelId}.", Times.Once()); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-to-image-response.txt b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-to-image-response.txt new file mode 100644 index 000000000000..7d8f7327a5ec --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-to-image-response.txt @@ -0,0 +1,8 @@ +{ + "created": 1702575371, + "data": [ + { + "url": "https://image-url/" + } + ] +} \ No newline at end of file diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs index d11e2799addd..aa15de012084 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs @@ -13,8 +13,6 @@ This class was created to simplify any Text Embeddings Support from the v1 Clien using System.Threading.Tasks; using OpenAI.Embeddings; -#pragma warning disable CA2208 // Instantiate argument exceptions correctly - namespace Microsoft.SemanticKernel.Connectors.OpenAI; /// diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.TextToImage.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.TextToImage.cs new file mode 100644 index 000000000000..26d8480fd004 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.TextToImage.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* +Phase 02 + +- This class was created focused in the Image Generation using the SDK client instead of the own client in V1. +- Added Checking for empty or whitespace prompt. +- Removed the format parameter as this is never called in V1 code. Plan to implement it in the future once we change the ITextToImageService abstraction, using PromptExecutionSettings. +- Allow custom size for images when the endpoint is not the default OpenAI v1 endpoint. +*/ + +using System.ClientModel; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Images; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Base class for AI clients that provides common functionality for interacting with OpenAI services. +/// +internal partial class ClientCore +{ + /// + /// Generates an image with the provided configuration. + /// + /// Prompt to generate the image + /// Width of the image + /// Height of the image + /// The to monitor for cancellation requests. The default is . + /// Url of the generated image + internal async Task GenerateImageAsync( + string prompt, + int width, + int height, + CancellationToken cancellationToken) + { + Verify.NotNullOrWhiteSpace(prompt); + + var size = new GeneratedImageSize(width, height); + + var imageOptions = new ImageGenerationOptions() + { + Size = size, + ResponseFormat = GeneratedImageFormat.Uri + }; + + ClientResult response = await RunRequestAsync(() => this.Client.GetImageClient(this.ModelId).GenerateImageAsync(prompt, imageOptions, cancellationToken)).ConfigureAwait(false); + var generatedImage = response.Value; + + return generatedImage.ImageUri?.ToString() ?? throw new KernelException("The generated image is not in url format"); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs index 12ca2f3d92fe..a6be6d20aa46 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs @@ -4,6 +4,11 @@ Phase 01 : This class was created adapting and merging ClientCore and OpenAIClientCore classes. System.ClientModel changes were added and adapted to the code as this package is now used as a dependency over OpenAI package. All logic from original ClientCore and OpenAIClientCore were preserved. + +Phase 02 : +- Moved AddAttributes usage to the constructor, avoiding the need verify and adding it in the services. +- Added ModelId attribute to the OpenAIClient constructor. +- Added WhiteSpace instead of empty string for ApiKey to avoid exception from OpenAI Client on custom endpoints added an issue in OpenAI SDK repo. https://github.com/openai/openai-dotnet/issues/90 */ using System; @@ -17,6 +22,7 @@ All logic from original ClientCore and OpenAIClientCore were preserved. using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.Services; using OpenAI; #pragma warning disable CA2208 // Instantiate argument exceptions correctly @@ -28,6 +34,16 @@ namespace Microsoft.SemanticKernel.Connectors.OpenAI; /// internal partial class ClientCore { + /// + /// White space constant. + /// + private const string SingleSpace = " "; + + /// + /// Gets the attribute name used to store the organization in the dictionary. + /// + internal const string OrganizationKey = "Organization"; + /// /// Default OpenAI API endpoint. /// @@ -63,15 +79,15 @@ internal partial class ClientCore /// /// Model name. /// OpenAI API Key. - /// OpenAI compatible API endpoint. /// OpenAI Organization Id (usually optional). + /// OpenAI compatible API endpoint. /// Custom for HTTP requests. /// The to use for logging. If null, no logging will be performed. internal ClientCore( string modelId, string? apiKey = null, - Uri? endpoint = null, string? organizationId = null, + Uri? endpoint = null, HttpClient? httpClient = null, ILogger? logger = null) { @@ -80,6 +96,8 @@ internal ClientCore( this.Logger = logger ?? NullLogger.Instance; this.ModelId = modelId; + this.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + // Accepts the endpoint if provided, otherwise uses the default OpenAI endpoint. this.Endpoint = endpoint ?? httpClient?.BaseAddress; if (this.Endpoint is null) @@ -87,14 +105,23 @@ internal ClientCore( Verify.NotNullOrWhiteSpace(apiKey); // For Public OpenAI Endpoint a key must be provided. this.Endpoint = new Uri(OpenAIV1Endpoint); } + else if (string.IsNullOrEmpty(apiKey)) + { + // Avoids an exception from OpenAI Client when a custom endpoint is provided without an API key. + apiKey = SingleSpace; + } + + this.AddAttribute(AIServiceExtensions.EndpointKey, this.Endpoint.ToString()); var options = GetOpenAIClientOptions(httpClient, this.Endpoint); if (!string.IsNullOrWhiteSpace(organizationId)) { options.AddPolicy(new AddHeaderRequestPolicy("OpenAI-Organization", organizationId!), PipelinePosition.PerCall); + + this.AddAttribute(ClientCore.OrganizationKey, organizationId); } - this.Client = new OpenAIClient(apiKey ?? string.Empty, options); + this.Client = new OpenAIClient(apiKey!, options); } /// @@ -116,6 +143,8 @@ internal ClientCore( this.Logger = logger ?? NullLogger.Instance; this.ModelId = modelId; this.Client = openAIClient; + + this.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); } /// diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs new file mode 100644 index 000000000000..567d82726e4b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.TextToImage; +using OpenAI; + +namespace Microsoft.SemanticKernel; + +/// +/// Sponsor extensions class for . +/// +public static class OpenAIKernelBuilderExtensions +{ + #region Text Embedding + /// + /// Adds the OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// Non-default endpoint for the OpenAI API. + /// 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 AddOpenAITextEmbeddingGeneration( + this IKernelBuilder builder, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + Uri? endpoint = null, + HttpClient? httpClient = null, + int? dimensions = null) + { + Verify.NotNull(builder); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextEmbeddingGenerationService( + modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService(), + dimensions)); + + return builder; + } + + /// + /// Adds the OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// 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 + /// 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 AddOpenAITextEmbeddingGeneration( + this IKernelBuilder builder, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null, + int? dimensions = null) + { + Verify.NotNull(builder); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextEmbeddingGenerationService( + modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService(), + dimensions)); + + return builder; + } + #endregion + + #region Text to Image + /// + /// Add the OpenAI Dall-E text to image service to the list + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// 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 + /// The same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddOpenAITextToImage( + this IKernelBuilder builder, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(builder); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextToImageService( + modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService())); + + return builder; + } + + /// + /// Add the OpenAI Dall-E text to image service to the list + /// + /// The instance to augment. + /// The model to use for image generation. + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// Non-default endpoint for the OpenAI API. + /// The HttpClient to use with this service. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddOpenAITextToImage( + this IKernelBuilder builder, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + Uri? endpoint = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextToImageService( + modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService())); + + return builder; + } + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs new file mode 100644 index 000000000000..77355de7f24e --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.TextToImage; +using OpenAI; + +namespace Microsoft.SemanticKernel; + +/* Phase 02 +- Add endpoint parameter for both Embedding and TextToImage services extensions. +- Removed unnecessary Validation checks (that are already happening in the service/client constructors) +- Added openAIClient extension for TextToImage service. +- Changed parameters order for TextToImage service extension (modelId comes first). +- Made modelId a required parameter of TextToImage services. + +*/ +/// +/// Sponsor extensions class for . +/// +public static class OpenAIServiceCollectionExtensions +{ + #region Text Embedding + /// + /// Adds the OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// Non-default endpoint for the OpenAI API. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddOpenAITextEmbeddingGeneration( + this IServiceCollection services, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + int? dimensions = null, + Uri? endpoint = null) + { + Verify.NotNull(services); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextEmbeddingGenerationService( + modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService(), + dimensions)); + } + + /// + /// Adds the OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// The OpenAI model id. + /// 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 + /// 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 AddOpenAITextEmbeddingGeneration(this IServiceCollection services, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null, + int? dimensions = null) + { + Verify.NotNull(services); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextEmbeddingGenerationService( + modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService(), + dimensions)); + } + #endregion + + #region Text to Image + /// + /// Add the OpenAI Dall-E text to image service to the list + /// + /// The instance to augment. + /// The model to use for image generation. + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// Non-default endpoint for the OpenAI API. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddOpenAITextToImage(this IServiceCollection services, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + Uri? endpoint = null) + { + Verify.NotNull(services); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextToImageService( + modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService())); + } + + /// + /// Adds the OpenAI text embeddings service to the list. + /// + /// The instance to augment. + /// The OpenAI model id. + /// 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 + /// 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 AddOpenAITextToImage(this IServiceCollection services, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null, + int? dimensions = null) + { + Verify.NotNull(services); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextToImageService( + modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService())); + } + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs index 49915031b7fc..a4dd48ba75e3 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs @@ -8,11 +8,14 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel.Embeddings; -using Microsoft.SemanticKernel.Services; using OpenAI; namespace Microsoft.SemanticKernel.Connectors.OpenAI; +/* Phase 02 +Adding the non-default endpoint parameter to the constructor. +*/ + /// /// OpenAI implementation of /// @@ -28,6 +31,7 @@ public sealed class OpenAITextEmbeddingGenerationService : ITextEmbeddingGenerat /// Model name /// OpenAI API Key /// OpenAI Organization Id (usually optional) + /// Non-default endpoint for the OpenAI API /// Custom for HTTP requests. /// 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. @@ -35,6 +39,7 @@ public OpenAITextEmbeddingGenerationService( string modelId, string apiKey, string? organization = null, + Uri? endpoint = null, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, int? dimensions = null) @@ -42,12 +47,11 @@ public OpenAITextEmbeddingGenerationService( this._core = new( modelId: modelId, apiKey: apiKey, + endpoint: endpoint, organizationId: organization, httpClient: httpClient, logger: loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); - this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); - this._dimensions = dimensions; } @@ -65,8 +69,6 @@ public OpenAITextEmbeddingGenerationService( int? dimensions = null) { this._core = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); - this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); - this._dimensions = dimensions; } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs new file mode 100644 index 000000000000..55eca0e112eb --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.TextToImage; +using OpenAI; + +/* Phase 02 +- Breaking the current constructor parameter order to follow the same order as the other services. +- Added custom endpoint support, and removed ApiKey validation, as it is performed by the ClientCore when the Endpoint is not provided. +- Added custom OpenAIClient support. +- Updated "organization" parameter to "organizationId". +- "modelId" parameter is now required in the constructor. + +- Added OpenAIClient breaking glass constructor. +*/ + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// OpenAI text to image service. +/// +[Experimental("SKEXP0010")] +public class OpenAITextToImageService : ITextToImageService +{ + private readonly ClientCore _core; + + /// + public IReadOnlyDictionary Attributes => this._core.Attributes; + + /// + /// Initializes a new instance of the class. + /// + /// The model to use for image generation. + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// Non-default endpoint for the OpenAI API. + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public OpenAITextToImageService( + string modelId, + string? apiKey = null, + string? organizationId = null, + Uri? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + this._core = new(modelId, apiKey, organizationId, endpoint, httpClient, loggerFactory?.CreateLogger(this.GetType())); + } + + /// + /// Initializes a new instance of the class. + /// + /// Model name + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public OpenAITextToImageService( + string modelId, + OpenAIClient openAIClient, + ILoggerFactory? loggerFactory = null) + { + this._core = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); + } + + /// + public Task GenerateImageAsync(string description, int width, int height, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + this._core.LogActionDetails(); + return this._core.GenerateImageAsync(description, width, height, cancellationToken); + } +} diff --git a/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs index 6eca1909a546..bccc92bfa0f3 100644 --- a/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs +++ b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs @@ -19,7 +19,7 @@ public sealed class OpenAITextEmbeddingTests .AddUserSecrets() .Build(); - [Theory]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + [Theory(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] [InlineData("test sentence")] public async Task OpenAITestAsync(string testInputString) { @@ -38,7 +38,7 @@ public async Task OpenAITestAsync(string testInputString) Assert.Equal(3, batchResult.Count); } - [Theory]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + [Theory(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] [InlineData(null, 3072)] [InlineData(1024, 1024)] public async Task OpenAIWithDimensionsAsync(int? dimensions, int expectedVectorLength) diff --git a/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextToImageTests.cs b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextToImageTests.cs new file mode 100644 index 000000000000..812d41677b28 --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextToImageTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.TextToImage; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; +public sealed class OpenAITextToImageTests +{ + 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(); + + [Theory(Skip = "This test is for manual verification.")] + [InlineData("dall-e-2", 512, 512)] + [InlineData("dall-e-3", 1024, 1024)] + public async Task OpenAITextToImageByModelTestAsync(string modelId, int width, int height) + { + // Arrange + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("OpenAITextToImage").Get(); + Assert.NotNull(openAIConfiguration); + + var kernel = Kernel.CreateBuilder() + .AddOpenAITextToImage(modelId, apiKey: openAIConfiguration.ApiKey) + .Build(); + + var service = kernel.GetRequiredService(); + + // Act + var result = await service.GenerateImageAsync("The sun rises in the east and sets in the west.", width, height); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + } +} diff --git a/dotnet/src/InternalUtilities/test/MoqExtensions.cs b/dotnet/src/InternalUtilities/test/MoqExtensions.cs new file mode 100644 index 000000000000..8fb435e288f9 --- /dev/null +++ b/dotnet/src/InternalUtilities/test/MoqExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.Logging; +using Moq; + +#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + +internal static class MoqExtensions +{ + public static void VerifyLog(this Mock> logger, LogLevel logLevel, string message, Times times) + { + logger.Verify( + x => x.Log( + It.Is(l => l == logLevel), + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(message)), + It.IsAny(), + It.IsAny>()), + times); + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/TextToImage/ITextToImageService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/TextToImage/ITextToImageService.cs index c4c967445a6b..b30f78f3c0ca 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/TextToImage/ITextToImageService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/TextToImage/ITextToImageService.cs @@ -5,6 +5,10 @@ using System.Threading.Tasks; using Microsoft.SemanticKernel.Services; +/* Phase 02 +- Changing "description" parameter to "prompt" to better match the OpenAI API and avoid confusion. +*/ + namespace Microsoft.SemanticKernel.TextToImage; /// @@ -16,7 +20,7 @@ public interface ITextToImageService : IAIService /// /// Generate an image matching the given description /// - /// Image description + /// Image generation prompt /// Image width in pixels /// Image height in pixels /// The containing services, plugins, and other state for use throughout the operation.