diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientResultExceptionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientResultExceptionExtensionsTests.cs deleted file mode 100644 index d810b2d2a470..000000000000 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientResultExceptionExtensionsTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.ClientModel; -using System.ClientModel.Primitives; -using System.IO; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.AzureOpenAI; - -namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; - -/// -/// Unit tests for class. -/// -public sealed class ClientResultExceptionExtensionsTests -{ - [Fact] - public void ToHttpOperationExceptionWithContentReturnsValidException() - { - // Arrange - using var response = new FakeResponse("Response Content", 500); - var exception = new ClientResultException(response); - - // Act - var actualException = exception.ToHttpOperationException(); - - // Assert - Assert.IsType(actualException); - Assert.Equal(HttpStatusCode.InternalServerError, actualException.StatusCode); - Assert.Equal("Response Content", actualException.ResponseContent); - Assert.Same(exception, actualException.InnerException); - } - - #region private - - private sealed class FakeResponse(string responseContent, int status) : PipelineResponse - { - private readonly string _responseContent = responseContent; - public override BinaryData Content => BinaryData.FromString(this._responseContent); - public override int Status { get; } = status; - public override string ReasonPhrase => "Reason Phrase"; - public override Stream? ContentStream { get => null; set => throw new NotImplementedException(); } - protected override PipelineResponseHeaders HeadersCore => throw new NotImplementedException(); - public override BinaryData BufferContent(CancellationToken cancellationToken = default) => new(this._responseContent); - public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override void Dispose() { } - } - - #endregion -} 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 0a100b3c13a6..80e71aa16760 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj @@ -37,7 +37,7 @@ - + diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs index f296000c5245..bfa71f7e5ab3 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.AudioToText; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Services; +using Microsoft.SemanticKernel.TextToAudio; using Microsoft.SemanticKernel.TextToImage; using OpenAI; using Xunit; @@ -70,4 +72,64 @@ public void ItCanAddTextToImageServiceWithOpenAIClient() // Assert Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); } + + [Fact] + public void ItCanAddTextToAudioService() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAITextToAudio("model", "key") + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddTextToAudioServiceWithOpenAIClient() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAITextToAudio("model", new OpenAIClient("key")) + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddAudioToTextService() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAIAudioToText("model", "key") + .Build() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddAudioToTextServiceWithOpenAIClient() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAIAudioToText("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 index 65db68eea180..79c8024bb93f 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.AudioToText; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Services; +using Microsoft.SemanticKernel.TextToAudio; using Microsoft.SemanticKernel.TextToImage; using OpenAI; using Xunit; @@ -71,4 +73,64 @@ public void ItCanAddImageToTextServiceWithOpenAIClient() // Assert Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); } + + [Fact] + public void ItCanAddTextToAudioService() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAITextToAudio("model", "key") + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddTextToAudioServiceWithOpenAIClient() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAITextToAudio("model", new OpenAIClient("key")) + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddAudioToTextService() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAIAudioToText("model", "key") + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public void ItCanAddAudioToTextServiceWithOpenAIClient() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAIAudioToText("model", new OpenAIClient("key")) + .BuildServiceProvider() + .GetRequiredService(); + + // Assert + Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs new file mode 100644 index 000000000000..9648670d3de5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Moq; +using OpenAI; +using Xunit; +using static Microsoft.SemanticKernel.Connectors.OpenAI.OpenAIAudioToTextExecutionSettings; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Unit tests for class. +/// +public sealed class OpenAIAudioToTextServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Mock _mockLoggerFactory; + + public OpenAIAudioToTextServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + this._mockLoggerFactory = new Mock(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWithApiKeyWorksCorrectly(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new OpenAIAudioToTextService("model-id", "api-key", "organization", loggerFactory: this._mockLoggerFactory.Object) : + new OpenAIAudioToTextService("model-id", "api-key", "organization"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWithOpenAIClientWorksCorrectly(bool includeLoggerFactory) + { + // Arrange & Act + var client = new OpenAIClient("key"); + var service = includeLoggerFactory ? + new OpenAIAudioToTextService("model-id", client, loggerFactory: this._mockLoggerFactory.Object) : + new OpenAIAudioToTextService("model-id", client); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Default }, "0")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Word }, "word")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Segment }, "segment")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Segment, TimeStampGranularities.Word }, "word", "segment")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Word, TimeStampGranularities.Segment }, "word", "segment")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Default, TimeStampGranularities.Word }, "word", "0")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Word, TimeStampGranularities.Default }, "word", "0")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Default, TimeStampGranularities.Segment }, "segment", "0")] + [InlineData(new TimeStampGranularities[] { TimeStampGranularities.Segment, TimeStampGranularities.Default }, "segment", "0")] + public async Task GetTextContentGranularitiesWorksAsync(TimeStampGranularities[] granularities, params string[] expectedGranularities) + { + // Arrange + var service = new OpenAIAudioToTextService("model-id", "api-key", httpClient: this._httpClient); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("Test audio-to-text response") + }; + + // Act + var settings = new OpenAIAudioToTextExecutionSettings("file.mp3") { Granularities = granularities }; + var result = await service.GetTextContentsAsync(new AudioContent(new BinaryData("data"), mimeType: null), settings); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestContent); + Assert.NotNull(result); + + var multiPartData = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + var multiPartBreak = multiPartData.Substring(0, multiPartData.IndexOf("\r\n", StringComparison.OrdinalIgnoreCase)); + + foreach (var granularity in expectedGranularities) + { + var expectedMultipart = $"{granularity}\r\n{multiPartBreak}"; + Assert.Contains(expectedMultipart, multiPartData); + } + } + + [Fact] + public async Task GetTextContentByDefaultWorksCorrectlyAsync() + { + // Arrange + var service = new OpenAIAudioToTextService("model-id", "api-key", "organization", null, this._httpClient); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("Test audio-to-text response") + }; + + // Act + var result = await service.GetTextContentsAsync(new AudioContent(new BinaryData("data"), mimeType: null), new OpenAIAudioToTextExecutionSettings("file.mp3")); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test audio-to-text response", result[0].Text); + } + + [Fact] + public async Task GetTextContentsDoesLogActionAsync() + { + // Assert + var modelId = "whisper-1"; + 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 OpenAIAudioToTextService(modelId, "apiKey", httpClient: this._httpClient, loggerFactory: this._mockLoggerFactory.Object); + + // Act + await sut.GetTextContentsAsync(new(new byte[] { 0x01, 0x02 }, "text/plain")); + + // Assert + logger.VerifyLog(LogLevel.Information, $"Action: {nameof(OpenAIAudioToTextService.GetTextContentsAsync)}. OpenAI Model ID: {modelId}.", Times.Once()); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs new file mode 100644 index 000000000000..e8fdb7b46b1e --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Moq; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Unit tests for class. +/// +public sealed class OpenAITextToAudioServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Mock _mockLoggerFactory; + + public OpenAITextToAudioServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + this._mockLoggerFactory = new Mock(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWithApiKeyWorksCorrectly(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new OpenAITextToAudioService("model-id", "api-key", "organization", loggerFactory: this._mockLoggerFactory.Object) : + new OpenAITextToAudioService("model-id", "api-key", "organization"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [MemberData(nameof(ExecutionSettings))] + public async Task GetAudioContentWithInvalidSettingsThrowsExceptionAsync(OpenAITextToAudioExecutionSettings? settings, Type expectedExceptionType) + { + // Arrange + var service = new OpenAITextToAudioService("model-id", "api-key", "organization", null, this._httpClient); + await using var stream = new MemoryStream([0x00, 0x00, 0xFF, 0x7F]); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + }; + + // Act + var exception = await Assert.ThrowsAnyAsync(async () => await service.GetAudioContentsAsync("Some text", settings)); + + // Assert + Assert.NotNull(exception); + Assert.IsType(expectedExceptionType, exception); + } + + [Fact] + public async Task GetAudioContentByDefaultWorksCorrectlyAsync() + { + // Arrange + byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F]; + + var service = new OpenAITextToAudioService("model-id", "api-key", "organization", null, this._httpClient); + await using var stream = new MemoryStream(expectedByteArray); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + }; + + // Act + var result = await service.GetAudioContentsAsync("Some text"); + + // Assert + var audioData = result[0].Data!.Value; + Assert.False(audioData.IsEmpty); + Assert.True(audioData.Span.SequenceEqual(expectedByteArray)); + } + + [Theory] + [InlineData("echo", "wav")] + [InlineData("fable", "opus")] + [InlineData("onyx", "flac")] + [InlineData("nova", "aac")] + [InlineData("shimmer", "pcm")] + public async Task GetAudioContentVoicesWorksCorrectlyAsync(string voice, string format) + { + // Arrange + byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F]; + + var service = new OpenAITextToAudioService("model-id", "api-key", "organization", null, this._httpClient); + await using var stream = new MemoryStream(expectedByteArray); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + }; + + // Act + var result = await service.GetAudioContentsAsync("Some text", new OpenAITextToAudioExecutionSettings(voice) { ResponseFormat = format }); + + // Assert + var requestBody = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + var audioData = result[0].Data!.Value; + Assert.Contains($"\"voice\":\"{voice}\"", requestBody); + Assert.Contains($"\"response_format\":\"{format}\"", requestBody); + Assert.False(audioData.IsEmpty); + Assert.True(audioData.Span.SequenceEqual(expectedByteArray)); + } + + [Fact] + public async Task GetAudioContentThrowsWhenVoiceIsNotSupportedAsync() + { + // Arrange + byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F]; + + var service = new OpenAITextToAudioService("model-id", "api-key", "organization", null, this._httpClient); + + // Act & Assert + await Assert.ThrowsAsync(async () => await service.GetAudioContentsAsync("Some text", new OpenAITextToAudioExecutionSettings("voice"))); + } + + [Fact] + public async Task GetAudioContentThrowsWhenFormatIsNotSupportedAsync() + { + // Arrange + byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F]; + + var service = new OpenAITextToAudioService("model-id", "api-key", "organization", null, this._httpClient); + + // Act & Assert + await Assert.ThrowsAsync(async () => await service.GetAudioContentsAsync("Some text", new OpenAITextToAudioExecutionSettings() { ResponseFormat = "not supported" })); + } + + [Theory] + [InlineData(true, "http://local-endpoint")] + [InlineData(false, "https://api.openai.com")] + public async Task GetAudioContentUsesValidBaseUrlAsync(bool useHttpClientBaseAddress, string expectedBaseAddress) + { + // Arrange + byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F]; + + if (useHttpClientBaseAddress) + { + this._httpClient.BaseAddress = new Uri("http://local-endpoint"); + } + + var service = new OpenAITextToAudioService("model-id", "api-key", "organization", null, this._httpClient); + await using var stream = new MemoryStream(expectedByteArray); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + }; + + // Act + var result = await service.GetAudioContentsAsync("Some text"); + + // Assert + Assert.StartsWith(expectedBaseAddress, this._messageHandlerStub.RequestUri!.AbsoluteUri, StringComparison.InvariantCulture); + } + + [Fact] + public async Task GetAudioContentDoesLogActionAsync() + { + // Assert + var modelId = "whisper-1"; + 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 OpenAITextToAudioService(modelId, "apiKey", httpClient: this._httpClient, loggerFactory: this._mockLoggerFactory.Object); + + // Act + await sut.GetAudioContentsAsync("description"); + + // Assert + logger.VerifyLog(LogLevel.Information, $"Action: {nameof(OpenAITextToAudioService.GetAudioContentsAsync)}. OpenAI Model ID: {modelId}.", Times.Once()); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + public static TheoryData ExecutionSettings => new() + { + { new OpenAITextToAudioExecutionSettings("invalid"), typeof(NotSupportedException) }, + }; +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs index 919b864327e8..f449059e8ab5 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs @@ -11,7 +11,7 @@ using OpenAI; using Xunit; -namespace SemanticKernel.Connectors.UnitTests.OpenAI.Services; +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; /// /// Unit tests for class. diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Settings/OpenAIAudioToTextExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Settings/OpenAIAudioToTextExecutionSettingsTests.cs new file mode 100644 index 000000000000..e01345c82f03 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Settings/OpenAIAudioToTextExecutionSettingsTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UniTests.Settings; + +/// +/// Unit tests for class. +/// +public sealed class OpenAIAudioToTextExecutionSettingsTests +{ + [Fact] + public void ItReturnsDefaultSettingsWhenSettingsAreNull() + { + Assert.NotNull(OpenAIAudioToTextExecutionSettings.FromExecutionSettings(null)); + } + + [Fact] + public void ItReturnsValidOpenAIAudioToTextExecutionSettings() + { + // Arrange + var audioToTextSettings = new OpenAIAudioToTextExecutionSettings("file.mp3") + { + ModelId = "model_id", + Language = "en", + Prompt = "prompt", + ResponseFormat = "text", + Temperature = 0.2f + }; + + // Act + var settings = OpenAIAudioToTextExecutionSettings.FromExecutionSettings(audioToTextSettings); + + // Assert + Assert.Same(audioToTextSettings, settings); + } + + [Fact] + public void ItCreatesOpenAIAudioToTextExecutionSettingsFromJson() + { + // Arrange + var json = """ + { + "model_id": "model_id", + "language": "en", + "filename": "file.mp3", + "prompt": "prompt", + "response_format": "text", + "temperature": 0.2 + } + """; + + var executionSettings = JsonSerializer.Deserialize(json); + + // Act + var settings = OpenAIAudioToTextExecutionSettings.FromExecutionSettings(executionSettings); + + // Assert + Assert.NotNull(settings); + Assert.Equal("model_id", settings.ModelId); + Assert.Equal("en", settings.Language); + Assert.Equal("file.mp3", settings.Filename); + Assert.Equal("prompt", settings.Prompt); + Assert.Equal("text", settings.ResponseFormat); + Assert.Equal(0.2f, settings.Temperature); + } + + [Fact] + public void ItClonesAllProperties() + { + var settings = new OpenAIAudioToTextExecutionSettings() + { + ModelId = "model_id", + Language = "en", + Prompt = "prompt", + ResponseFormat = "text", + Temperature = 0.2f, + Filename = "something.mp3", + }; + + var clone = (OpenAIAudioToTextExecutionSettings)settings.Clone(); + Assert.NotSame(settings, clone); + + Assert.Equal("model_id", clone.ModelId); + Assert.Equal("en", clone.Language); + Assert.Equal("prompt", clone.Prompt); + Assert.Equal("text", clone.ResponseFormat); + Assert.Equal(0.2f, clone.Temperature); + Assert.Equal("something.mp3", clone.Filename); + } + + [Fact] + public void ItFreezesAndPreventsMutation() + { + var settings = new OpenAIAudioToTextExecutionSettings() + { + ModelId = "model_id", + Language = "en", + Prompt = "prompt", + ResponseFormat = "text", + Temperature = 0.2f, + Filename = "something.mp3", + }; + + settings.Freeze(); + Assert.True(settings.IsFrozen); + + Assert.Throws(() => settings.ModelId = "new_model"); + Assert.Throws(() => settings.Language = "some_format"); + Assert.Throws(() => settings.Prompt = "prompt"); + Assert.Throws(() => settings.ResponseFormat = "something"); + Assert.Throws(() => settings.Temperature = 0.2f); + Assert.Throws(() => settings.Filename = "something"); + + settings.Freeze(); // idempotent + Assert.True(settings.IsFrozen); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Settings/OpenAITextToAudioExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Settings/OpenAITextToAudioExecutionSettingsTests.cs new file mode 100644 index 000000000000..f30478e15acf --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Settings/OpenAITextToAudioExecutionSettingsTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UniTests.Settings; + +/// +/// Unit tests for class. +/// +public sealed class OpenAITextToAudioExecutionSettingsTests +{ + [Fact] + public void ItReturnsDefaultSettingsWhenSettingsAreNull() + { + Assert.NotNull(OpenAITextToAudioExecutionSettings.FromExecutionSettings(null)); + } + + [Fact] + public void ItReturnsValidOpenAITextToAudioExecutionSettings() + { + // Arrange + var textToAudioSettings = new OpenAITextToAudioExecutionSettings("voice") + { + ModelId = "model_id", + ResponseFormat = "mp3", + Speed = 1.0f + }; + + // Act + var settings = OpenAITextToAudioExecutionSettings.FromExecutionSettings(textToAudioSettings); + + // Assert + Assert.Same(textToAudioSettings, settings); + } + + [Fact] + public void ItCreatesOpenAIAudioToTextExecutionSettingsFromJson() + { + // Arrange + var json = """ + { + "model_id": "model_id", + "voice": "voice", + "response_format": "mp3", + "speed": 1.2 + } + """; + + var executionSettings = JsonSerializer.Deserialize(json); + + // Act + var settings = OpenAITextToAudioExecutionSettings.FromExecutionSettings(executionSettings); + + // Assert + Assert.NotNull(settings); + Assert.Equal("model_id", settings.ModelId); + Assert.Equal("voice", settings.Voice); + Assert.Equal("mp3", settings.ResponseFormat); + Assert.Equal(1.2f, settings.Speed); + } + + [Fact] + public void ItClonesAllProperties() + { + var textToAudioSettings = new OpenAITextToAudioExecutionSettings() + { + ModelId = "some_model", + ResponseFormat = "some_format", + Speed = 3.14f, + Voice = "something" + }; + + var clone = (OpenAITextToAudioExecutionSettings)textToAudioSettings.Clone(); + Assert.NotSame(textToAudioSettings, clone); + + Assert.Equal("some_model", clone.ModelId); + Assert.Equal("some_format", clone.ResponseFormat); + Assert.Equal(3.14f, clone.Speed); + Assert.Equal("something", clone.Voice); + } + + [Fact] + public void ItFreezesAndPreventsMutation() + { + var textToAudioSettings = new OpenAITextToAudioExecutionSettings() + { + ModelId = "some_model", + ResponseFormat = "some_format", + Speed = 3.14f, + Voice = "something" + }; + + textToAudioSettings.Freeze(); + Assert.True(textToAudioSettings.IsFrozen); + + Assert.Throws(() => textToAudioSettings.ModelId = "new_model"); + Assert.Throws(() => textToAudioSettings.ResponseFormat = "some_format"); + Assert.Throws(() => textToAudioSettings.Speed = 3.14f); + Assert.Throws(() => textToAudioSettings.Voice = "something"); + + textToAudioSettings.Freeze(); // idempotent + Assert.True(textToAudioSettings.IsFrozen); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.AudioToText.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.AudioToText.cs new file mode 100644 index 000000000000..77ec85fe9c10 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.AudioToText.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Audio; + +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. + /// + /// Input audio to generate the text + /// Audio-to-text execution settings for the prompt + /// The to monitor for cancellation requests. The default is . + /// Url of the generated image + internal async Task> GetTextFromAudioContentsAsync( + AudioContent input, + PromptExecutionSettings? executionSettings, + CancellationToken cancellationToken) + { + if (!input.CanRead) + { + throw new ArgumentException("The input audio content is not readable.", nameof(input)); + } + + OpenAIAudioToTextExecutionSettings audioExecutionSettings = OpenAIAudioToTextExecutionSettings.FromExecutionSettings(executionSettings)!; + AudioTranscriptionOptions? audioOptions = AudioOptionsFromExecutionSettings(audioExecutionSettings); + + Verify.ValidFilename(audioExecutionSettings?.Filename); + + using var memoryStream = new MemoryStream(input.Data!.Value.ToArray()); + + AudioTranscription responseData = (await RunRequestAsync(() => this.Client.GetAudioClient(this.ModelId).TranscribeAudioAsync(memoryStream, audioExecutionSettings?.Filename, audioOptions)).ConfigureAwait(false)).Value; + + return [new(responseData.Text, this.ModelId, metadata: GetResponseMetadata(responseData))]; + } + + /// + /// Converts to type. + /// + /// Instance of . + /// Instance of . + private static AudioTranscriptionOptions? AudioOptionsFromExecutionSettings(OpenAIAudioToTextExecutionSettings executionSettings) + => new() + { + Granularities = ConvertToAudioTimestampGranularities(executionSettings!.Granularities), + Language = executionSettings.Language, + Prompt = executionSettings.Prompt, + Temperature = executionSettings.Temperature + }; + + private static AudioTimestampGranularities ConvertToAudioTimestampGranularities(IEnumerable? granularities) + { + AudioTimestampGranularities result = AudioTimestampGranularities.Default; + + if (granularities is not null) + { + foreach (var granularity in granularities) + { + var openAIGranularity = granularity switch + { + OpenAIAudioToTextExecutionSettings.TimeStampGranularities.Word => AudioTimestampGranularities.Word, + OpenAIAudioToTextExecutionSettings.TimeStampGranularities.Segment => AudioTimestampGranularities.Segment, + _ => AudioTimestampGranularities.Default + }; + + result |= openAIGranularity; + } + } + + return result; + } + + private static Dictionary GetResponseMetadata(AudioTranscription audioTranscription) + => new(3) + { + [nameof(audioTranscription.Language)] = audioTranscription.Language, + [nameof(audioTranscription.Duration)] = audioTranscription.Duration, + [nameof(audioTranscription.Segments)] = audioTranscription.Segments + }; +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.TextToAudio.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.TextToAudio.cs new file mode 100644 index 000000000000..75e484a489aa --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.TextToAudio.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Audio; + +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 + /// Text to Audio execution settings for the prompt + /// The to monitor for cancellation requests. The default is . + /// Url of the generated image + internal async Task> GetAudioContentsAsync( + string prompt, + PromptExecutionSettings? executionSettings, + CancellationToken cancellationToken) + { + Verify.NotNullOrWhiteSpace(prompt); + + OpenAITextToAudioExecutionSettings? audioExecutionSettings = OpenAITextToAudioExecutionSettings.FromExecutionSettings(executionSettings); + var (responseFormat, mimeType) = GetGeneratedSpeechFormatAndMimeType(audioExecutionSettings?.ResponseFormat); + SpeechGenerationOptions options = new() + { + ResponseFormat = responseFormat, + Speed = audioExecutionSettings?.Speed, + }; + + ClientResult response = await RunRequestAsync(() => this.Client.GetAudioClient(this.ModelId).GenerateSpeechFromTextAsync(prompt, GetGeneratedSpeechVoice(audioExecutionSettings?.Voice), options, cancellationToken)).ConfigureAwait(false); + + return [new AudioContent(response.Value.ToArray(), mimeType)]; + } + + private static GeneratedSpeechVoice GetGeneratedSpeechVoice(string? voice) + => voice?.ToUpperInvariant() switch + { + "ALLOY" => GeneratedSpeechVoice.Alloy, + "ECHO" => GeneratedSpeechVoice.Echo, + "FABLE" => GeneratedSpeechVoice.Fable, + "ONYX" => GeneratedSpeechVoice.Onyx, + "NOVA" => GeneratedSpeechVoice.Nova, + "SHIMMER" => GeneratedSpeechVoice.Shimmer, + _ => throw new NotSupportedException($"The voice '{voice}' is not supported."), + }; + + private static (GeneratedSpeechFormat Format, string MimeType) GetGeneratedSpeechFormatAndMimeType(string? format) + => format?.ToUpperInvariant() switch + { + "WAV" => (GeneratedSpeechFormat.Wav, "audio/wav"), + "MP3" => (GeneratedSpeechFormat.Mp3, "audio/mpeg"), + "OPUS" => (GeneratedSpeechFormat.Opus, "audio/opus"), + "FLAC" => (GeneratedSpeechFormat.Flac, "audio/flac"), + "AAC" => (GeneratedSpeechFormat.Aac, "audio/aac"), + "PCM" => (GeneratedSpeechFormat.Pcm, "audio/l16"), + _ => throw new NotSupportedException($"The format '{format}' is not supported.") + }; +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs index 567d82726e4b..ce4a4d9866e0 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs @@ -1,13 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. +/* Phase 4 +- Added missing OpenAIClient extensions for audio +- Updated the Experimental attribute to the correct value 0001 -> 0010 (Connector) + */ using System; using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.AudioToText; using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.TextToAudio; using Microsoft.SemanticKernel.TextToImage; using OpenAI; @@ -89,7 +95,7 @@ public static IKernelBuilder AddOpenAITextEmbeddingGeneration( #region Text to Image /// - /// Add the OpenAI Dall-E text to image service to the list + /// Add the OpenAI text-to-image service to the list /// /// The instance to augment. /// OpenAI model name, see https://platform.openai.com/docs/models @@ -115,7 +121,7 @@ public static IKernelBuilder AddOpenAITextToImage( } /// - /// Add the OpenAI Dall-E text to image service to the list + /// Add the OpenAI text-to-image service to the list /// /// The instance to augment. /// The model to use for image generation. @@ -149,4 +155,136 @@ public static IKernelBuilder AddOpenAITextToImage( return builder; } #endregion + + #region Text to Audio + + /// + /// Adds the OpenAI text-to-audio 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 same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddOpenAITextToAudio( + 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 OpenAITextToAudioService( + modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService())); + + return builder; + } + + /// + /// Add the OpenAI text-to-audio 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 AddOpenAITextToAudio( + this IKernelBuilder builder, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(builder); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextToAudioService( + modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService())); + + return builder; + } + + #endregion + + #region Audio-to-Text + + /// + /// Adds the OpenAI audio-to-text 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 same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddOpenAIAudioToText( + this IKernelBuilder builder, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + Uri? endpoint = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + + OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => + new(modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService()); + + builder.Services.AddKeyedSingleton(serviceId, (Func)Factory); + + return builder; + } + + /// + /// Adds the OpenAI audio-to-text service to the list. + /// + /// The instance to augment. + /// 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 same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddOpenAIAudioToText( + this IKernelBuilder builder, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(builder); + + OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => + new(modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService()); + + builder.Services.AddKeyedSingleton(serviceId, (Func)Factory); + + return builder; + } + + #endregion } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs index 77355de7f24e..769634c1cea7 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs @@ -4,9 +4,11 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.AudioToText; using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.TextToAudio; using Microsoft.SemanticKernel.TextToImage; using OpenAI; @@ -89,7 +91,7 @@ public static IServiceCollection AddOpenAITextEmbeddingGeneration(this IServiceC #region Text to Image /// - /// Add the OpenAI Dall-E text to image service to the list + /// Add the OpenAI text-to-image service to the list /// /// The instance to augment. /// The model to use for image generation. @@ -143,4 +145,125 @@ public static IServiceCollection AddOpenAITextToImage(this IServiceCollection se serviceProvider.GetService())); } #endregion + + #region Text to Audio + + /// + /// Adds the OpenAI text-to-audio 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 same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddOpenAITextToAudio( + 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 OpenAITextToAudioService( + modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService())); + } + + /// + /// Adds the OpenAI text-to-audio 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 IServiceCollection AddOpenAITextToAudio( + this IServiceCollection services, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(services); + + return services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAITextToAudioService( + modelId, + openAIClient ?? serviceProvider.GetRequiredService(), + serviceProvider.GetService())); + } + + #endregion + + #region Audio-to-Text + + /// + /// Adds the OpenAI audio-to-text 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 same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddOpenAIAudioToText( + this IServiceCollection services, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + Uri? endpoint = null) + { + Verify.NotNull(services); + + OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => + new(modelId, + apiKey, + orgId, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService()); + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + + /// + /// Adds the OpenAI audio-to-text service to the list. + /// + /// The instance to augment. + /// 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 same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddOpenAIAudioToText( + this IServiceCollection services, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(services); + + OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => + new(modelId, openAIClient ?? serviceProvider.GetRequiredService(), serviceProvider.GetService()); + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + #endregion } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs new file mode 100644 index 000000000000..a226d6c59040 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs @@ -0,0 +1,79 @@ +// 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.AudioToText; +using Microsoft.SemanticKernel.Services; +using OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// OpenAI text-to-audio service. +/// +[Experimental("SKEXP0010")] +public sealed class OpenAIAudioToTextService : IAudioToTextService +{ + /// + /// OpenAI text-to-audio client for HTTP operations. + /// + private readonly ClientCore _client; + + /// + /// Gets the attribute name used to store the organization in the dictionary. + /// + public static string OrganizationKey => "Organization"; + + /// + public IReadOnlyDictionary Attributes => this._client.Attributes; + + /// + /// Creates an instance of the with API key auth. + /// + /// 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. + public OpenAIAudioToTextService( + string modelId, + string apiKey, + string? organization = null, + Uri? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + this._client = new(modelId, apiKey, organization, endpoint, httpClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); + } + + /// + /// Creates an instance of the with API key auth. + /// + /// Model name + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public OpenAIAudioToTextService( + string modelId, + OpenAIClient openAIClient, + ILoggerFactory? loggerFactory = null) + { + this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); + } + + /// + public Task> GetTextContentsAsync( + AudioContent content, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + this._client.LogActionDetails(); + return this._client.GetTextFromAudioContentsAsync(content, executionSettings, cancellationToken); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs index a4dd48ba75e3..ea607b2565b3 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs @@ -22,7 +22,7 @@ Adding the non-default endpoint parameter to the constructor. [Experimental("SKEXP0010")] public sealed class OpenAITextEmbeddingGenerationService : ITextEmbeddingGenerationService { - private readonly ClientCore _core; + private readonly ClientCore _client; private readonly int? _dimensions; /// @@ -44,7 +44,7 @@ public OpenAITextEmbeddingGenerationService( ILoggerFactory? loggerFactory = null, int? dimensions = null) { - this._core = new( + this._client = new( modelId: modelId, apiKey: apiKey, endpoint: endpoint, @@ -68,12 +68,12 @@ public OpenAITextEmbeddingGenerationService( ILoggerFactory? loggerFactory = null, int? dimensions = null) { - this._core = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); + this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); this._dimensions = dimensions; } /// - public IReadOnlyDictionary Attributes => this._core.Attributes; + public IReadOnlyDictionary Attributes => this._client.Attributes; /// public Task>> GenerateEmbeddingsAsync( @@ -81,7 +81,7 @@ public Task>> GenerateEmbeddingsAsync( Kernel? kernel = null, CancellationToken cancellationToken = default) { - this._core.LogActionDetails(); - return this._core.GetEmbeddingsAsync(data, kernel, this._dimensions, cancellationToken); + this._client.LogActionDetails(); + return this._client.GetEmbeddingsAsync(data, kernel, this._dimensions, cancellationToken); } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs new file mode 100644 index 000000000000..87346eefb1b5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs @@ -0,0 +1,79 @@ +// 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.Services; +using Microsoft.SemanticKernel.TextToAudio; +using OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// OpenAI text-to-audio service. +/// +[Experimental("SKEXP0010")] +public sealed class OpenAITextToAudioService : ITextToAudioService +{ + /// + /// OpenAI text-to-audio client for HTTP operations. + /// + private readonly ClientCore _client; + + /// + /// Gets the attribute name used to store the organization in the dictionary. + /// + public static string OrganizationKey => "Organization"; + + /// + public IReadOnlyDictionary Attributes => this._client.Attributes; + + /// + /// Creates an instance of the with API key auth. + /// + /// 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. + public OpenAITextToAudioService( + string modelId, + string apiKey, + string? organization = null, + Uri? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + this._client = new(modelId, apiKey, organization, endpoint, httpClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); + } + + /// + /// Creates an instance of the with API key auth. + /// + /// Model name + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public OpenAITextToAudioService( + string modelId, + OpenAIClient openAIClient, + ILoggerFactory? loggerFactory = null) + { + this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); + } + + /// + public Task> GetAudioContentsAsync( + string text, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + this._client.LogActionDetails(); + return this._client.GetAudioContentsAsync(text, executionSettings, cancellationToken); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs index 55eca0e112eb..1a6038aa3f43 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs @@ -28,10 +28,10 @@ namespace Microsoft.SemanticKernel.Connectors.OpenAI; [Experimental("SKEXP0010")] public class OpenAITextToImageService : ITextToImageService { - private readonly ClientCore _core; + private readonly ClientCore _client; /// - public IReadOnlyDictionary Attributes => this._core.Attributes; + public IReadOnlyDictionary Attributes => this._client.Attributes; /// /// Initializes a new instance of the class. @@ -50,7 +50,7 @@ public OpenAITextToImageService( HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { - this._core = new(modelId, apiKey, organizationId, endpoint, httpClient, loggerFactory?.CreateLogger(this.GetType())); + this._client = new(modelId, apiKey, organizationId, endpoint, httpClient, loggerFactory?.CreateLogger(this.GetType())); } /// @@ -64,13 +64,13 @@ public OpenAITextToImageService( OpenAIClient openAIClient, ILoggerFactory? loggerFactory = null) { - this._core = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); + this._client = 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); + this._client.LogActionDetails(); + return this._client.GenerateImageAsync(description, width, height, cancellationToken); } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIAudioToTextExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIAudioToTextExecutionSettings.cs new file mode 100644 index 000000000000..5d87768c5ddd --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIAudioToTextExecutionSettings.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.Text; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Execution settings for OpenAI audio-to-text request. +/// +[Experimental("SKEXP0010")] +public sealed class OpenAIAudioToTextExecutionSettings : PromptExecutionSettings +{ + /// + /// Filename or identifier associated with audio data. + /// Should be in format {filename}.{extension} + /// + [JsonPropertyName("filename")] + public string Filename + { + get => this._filename; + + set + { + this.ThrowIfFrozen(); + this._filename = value; + } + } + + /// + /// An optional language of the audio data as two-letter ISO-639-1 language code (e.g. 'en' or 'es'). + /// + [JsonPropertyName("language")] + public string? Language + { + get => this._language; + + set + { + this.ThrowIfFrozen(); + this._language = value; + } + } + + /// + /// An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. + /// + [JsonPropertyName("prompt")] + public string? Prompt + { + get => this._prompt; + + set + { + this.ThrowIfFrozen(); + this._prompt = value; + } + } + + /// + /// The format of the transcript output, in one of these options: json, text, srt, verbose_json, or vtt. Default is 'json'. + /// + [JsonPropertyName("response_format")] + public string ResponseFormat + { + get => this._responseFormat; + + set + { + this.ThrowIfFrozen(); + this._responseFormat = value; + } + } + + /// + /// The sampling temperature, between 0 and 1. + /// Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + /// If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. + /// Default is 0. + /// + [JsonPropertyName("temperature")] + public float Temperature + { + get => this._temperature; + + set + { + this.ThrowIfFrozen(); + this._temperature = value; + } + } + + /// + /// The timestamp granularities to populate for this transcription. response_format must be set verbose_json to use timestamp granularities. Either or both of these options are supported: word, or segment. + /// + [JsonPropertyName("granularities")] + public IReadOnlyList? Granularities { get; set; } + + /// + /// Creates an instance of class with default filename - "file.mp3". + /// + public OpenAIAudioToTextExecutionSettings() + : this(DefaultFilename) + { + } + + /// + /// Creates an instance of class. + /// + /// Filename or identifier associated with audio data. Should be in format {filename}.{extension} + public OpenAIAudioToTextExecutionSettings(string filename) + { + this._filename = filename; + } + + /// + public override PromptExecutionSettings Clone() + { + return new OpenAIAudioToTextExecutionSettings(this.Filename) + { + ModelId = this.ModelId, + ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null, + Temperature = this.Temperature, + ResponseFormat = this.ResponseFormat, + Language = this.Language, + Prompt = this.Prompt + }; + } + + /// + /// Converts to derived type. + /// + /// Instance of . + /// Instance of . + public static OpenAIAudioToTextExecutionSettings? FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + if (executionSettings is null) + { + return new OpenAIAudioToTextExecutionSettings(); + } + + if (executionSettings is OpenAIAudioToTextExecutionSettings settings) + { + return settings; + } + + var json = JsonSerializer.Serialize(executionSettings); + + var openAIExecutionSettings = JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive); + + return openAIExecutionSettings!; + } + + /// + /// The timestamp granularities available to populate transcriptions. + /// + public enum TimeStampGranularities + { + /// + /// Not specified. + /// + Default = 0, + + /// + /// The transcription is segmented by word. + /// + Word = 1, + + /// + /// The timestamp of transcription is by segment. + /// + Segment = 2, + } + + #region private ================================================================================ + + private const string DefaultFilename = "file.mp3"; + + private float _temperature = 0; + private string _responseFormat = "json"; + private string _filename; + private string? _language; + private string? _prompt; + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAITextToAudioExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAITextToAudioExecutionSettings.cs new file mode 100644 index 000000000000..8fca703901eb --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAITextToAudioExecutionSettings.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* Phase 4 +Bringing the OpenAITextToAudioExecutionSettings class to the OpenAIV2 connector as is + +*/ + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.Text; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Execution settings for OpenAI text-to-audio request. +/// +[Experimental("SKEXP0001")] +public sealed class OpenAITextToAudioExecutionSettings : PromptExecutionSettings +{ + /// + /// The voice to use when generating the audio. Supported voices are alloy, echo, fable, onyx, nova, and shimmer. + /// + [JsonPropertyName("voice")] + public string Voice + { + get => this._voice; + + set + { + this.ThrowIfFrozen(); + this._voice = value; + } + } + + /// + /// The format to audio in. Supported formats are mp3, opus, aac, and flac. + /// + [JsonPropertyName("response_format")] + public string ResponseFormat + { + get => this._responseFormat; + + set + { + this.ThrowIfFrozen(); + this._responseFormat = value; + } + } + + /// + /// The speed of the generated audio. Select a value from 0.25 to 4.0. 1.0 is the default. + /// + [JsonPropertyName("speed")] + public float Speed + { + get => this._speed; + + set + { + this.ThrowIfFrozen(); + this._speed = value; + } + } + + /// + /// Creates an instance of class with default voice - "alloy". + /// + public OpenAITextToAudioExecutionSettings() + : this(DefaultVoice) + { + } + + /// + /// Creates an instance of class. + /// + /// The voice to use when generating the audio. Supported voices are alloy, echo, fable, onyx, nova, and shimmer. + public OpenAITextToAudioExecutionSettings(string? voice) + { + this._voice = voice ?? DefaultVoice; + } + + /// + public override PromptExecutionSettings Clone() + { + return new OpenAITextToAudioExecutionSettings(this.Voice) + { + ModelId = this.ModelId, + ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null, + Speed = this.Speed, + ResponseFormat = this.ResponseFormat + }; + } + + /// + /// Converts to derived type. + /// + /// Instance of . + /// Instance of . + public static OpenAITextToAudioExecutionSettings? FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + if (executionSettings is null) + { + return new OpenAITextToAudioExecutionSettings(); + } + + if (executionSettings is OpenAITextToAudioExecutionSettings settings) + { + return settings; + } + + var json = JsonSerializer.Serialize(executionSettings); + + var openAIExecutionSettings = JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive); + + return openAIExecutionSettings!; + } + + #region private ================================================================================ + + private const string DefaultVoice = "alloy"; + + private float _speed = 1.0f; + private string _responseFormat = "mp3"; + private string _voice; + + #endregion +} diff --git a/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAIAudioToTextTests.cs b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAIAudioToTextTests.cs new file mode 100644 index 000000000000..f1ead5f9b9c5 --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAIAudioToTextTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.AudioToText; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; + +public sealed class OpenAIAudioToTextTests() +{ + 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(); + + [Fact]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + public async Task OpenAIAudioToTextTestAsync() + { + // Arrange + const string Filename = "test_audio.wav"; + + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("OpenAIAudioToText").Get(); + Assert.NotNull(openAIConfiguration); + + var kernel = Kernel.CreateBuilder() + .AddOpenAIAudioToText(openAIConfiguration.ModelId, openAIConfiguration.ApiKey) + .Build(); + + var service = kernel.GetRequiredService(); + + await using Stream audio = File.OpenRead($"./TestData/{Filename}"); + var audioData = await BinaryData.FromStreamAsync(audio); + + // Act + var result = await service.GetTextContentAsync(new AudioContent(audioData, mimeType: "audio/wav"), new OpenAIAudioToTextExecutionSettings(Filename)); + + // Assert + Assert.Contains("The sun rises in the east and sets in the west.", result.Text, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextToAudioTests.cs b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextToAudioTests.cs new file mode 100644 index 000000000000..c2818abe2502 --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextToAudioTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.TextToAudio; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; + +public sealed class OpenAITextToAudioTests +{ + 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(); + + [Fact]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + public async Task OpenAITextToAudioTestAsync() + { + // Arrange + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("OpenAITextToAudio").Get(); + Assert.NotNull(openAIConfiguration); + + var kernel = Kernel.CreateBuilder() + .AddOpenAITextToAudio(openAIConfiguration.ModelId, openAIConfiguration.ApiKey) + .Build(); + + var service = kernel.GetRequiredService(); + + // Act + var result = await service.GetAudioContentAsync("The sun rises in the east and sets in the west."); + + // Assert + var audioData = result.Data!.Value; + Assert.False(audioData.IsEmpty); + } +} diff --git a/dotnet/src/IntegrationTestsV2/TestData/test_audio.wav b/dotnet/src/IntegrationTestsV2/TestData/test_audio.wav new file mode 100644 index 000000000000..c6d0edd9a931 Binary files /dev/null and b/dotnet/src/IntegrationTestsV2/TestData/test_audio.wav differ diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/ClientResultExceptionExtensions.cs b/dotnet/src/InternalUtilities/openai/Extensions/ClientResultExceptionExtensions.cs similarity index 94% rename from dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/ClientResultExceptionExtensions.cs rename to dotnet/src/InternalUtilities/openai/Extensions/ClientResultExceptionExtensions.cs index 7da92e5826ba..75cc074b862d 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/ClientResultExceptionExtensions.cs +++ b/dotnet/src/InternalUtilities/openai/Extensions/ClientResultExceptionExtensions.cs @@ -7,13 +7,14 @@ Preserved the logic as is. */ using System.ClientModel; +using System.Diagnostics.CodeAnalysis; using System.Net; - -namespace Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel; /// /// Provides extension methods for the class. /// +[ExcludeFromCodeCoverage] internal static class ClientResultExceptionExtensions { /// diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs similarity index 95% rename from dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs rename to dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs index 0b95f904d893..f7a4e947ec38 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs @@ -2,10 +2,9 @@ using System.ClientModel; using System.ClientModel.Primitives; -using Microsoft.SemanticKernel.Connectors.OpenAI; using Xunit; -namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; +namespace SemanticKernel.UnitTests.Utilities.OpenAI; public class ClientResultExceptionExtensionsTests { diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockPipelineResponse.cs b/dotnet/src/SemanticKernel.UnitTests/Utilities/OpenAI/MockPipelineResponse.cs similarity index 98% rename from dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockPipelineResponse.cs rename to dotnet/src/SemanticKernel.UnitTests/Utilities/OpenAI/MockPipelineResponse.cs index 6fe18b9c1684..2e254c53d04e 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockPipelineResponse.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Utilities/OpenAI/MockPipelineResponse.cs @@ -12,7 +12,7 @@ This class was imported and adapted from the System.ClientModel Unit Tests. using System.Threading; using System.Threading.Tasks; -namespace SemanticKernel.Connectors.OpenAI.UnitTests; +namespace SemanticKernel.UnitTests.Utilities.OpenAI; public class MockPipelineResponse : PipelineResponse { diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockResponseHeaders.cs b/dotnet/src/SemanticKernel.UnitTests/Utilities/OpenAI/MockResponseHeaders.cs similarity index 94% rename from dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockResponseHeaders.cs rename to dotnet/src/SemanticKernel.UnitTests/Utilities/OpenAI/MockResponseHeaders.cs index fceef64e4bae..97c9776b4b25 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockResponseHeaders.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Utilities/OpenAI/MockResponseHeaders.cs @@ -9,7 +9,7 @@ This class was imported and adapted from the System.ClientModel Unit Tests. using System.ClientModel.Primitives; using System.Collections.Generic; -namespace SemanticKernel.Connectors.OpenAI.UnitTests; +namespace SemanticKernel.UnitTests.Utilities.OpenAI; public class MockResponseHeaders : PipelineResponseHeaders {