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 046b5999bee6..0d89e02beb21 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Connectors.OpenAIV2.UnitTests.csproj @@ -1,4 +1,4 @@ - + SemanticKernel.Connectors.OpenAI.UnitTests @@ -7,7 +7,7 @@ true enable false - $(NoWarn);SKEXP0001;SKEXP0070;CS1591;IDE1006;RCS1261;CA1031;CA1308;CA1861;CA2007;CA2234;VSTHRD111 + $(NoWarn);SKEXP0001;SKEXP0070;SKEXP0010;CS1591;IDE1006;RCS1261;CA1031;CA1308;CA1861;CA2007;CA2234;VSTHRD111 @@ -29,11 +29,21 @@ - + + + + + + Always + + + Always + + diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs new file mode 100644 index 000000000000..a3415663459a --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.Http; +using Moq; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Core; +public partial class ClientCoreTests +{ + [Fact] + public void ItCanBeInstantiatedAndPropertiesSetAsExpected() + { + // Act + var logger = new Mock>().Object; + var openAIClient = new OpenAIClient(new ApiKeyCredential("key")); + + var clientCoreModelConstructor = new ClientCore("model1", "apiKey"); + var clientCoreOpenAIClientConstructor = new ClientCore("model1", openAIClient, logger: logger); + + // Assert + Assert.NotNull(clientCoreModelConstructor); + Assert.NotNull(clientCoreOpenAIClientConstructor); + + Assert.Equal("model1", clientCoreModelConstructor.ModelId); + Assert.Equal("model1", clientCoreOpenAIClientConstructor.ModelId); + + Assert.NotNull(clientCoreModelConstructor.Client); + Assert.NotNull(clientCoreOpenAIClientConstructor.Client); + Assert.Equal(openAIClient, clientCoreOpenAIClientConstructor.Client); + Assert.Equal(NullLogger.Instance, clientCoreModelConstructor.Logger); + Assert.Equal(logger, clientCoreOpenAIClientConstructor.Logger); + } + + [Theory] + [InlineData(null, null)] + [InlineData("http://localhost", null)] + [InlineData(null, "http://localhost")] + [InlineData("http://localhost-1", "http://localhost-2")] + public void ItUsesEndpointAsExpected(string? clientBaseAddress, string? providedEndpoint) + { + // Arrange + Uri? endpoint = null; + HttpClient? client = null; + if (providedEndpoint is not null) + { + endpoint = new Uri(providedEndpoint); + } + + if (clientBaseAddress is not null) + { + client = new HttpClient { BaseAddress = new Uri(clientBaseAddress) }; + } + + // Act + var clientCore = new ClientCore("model", "apiKey", endpoint: endpoint, httpClient: client); + + // Assert + Assert.Equal(endpoint ?? client?.BaseAddress ?? new Uri("https://api.openai.com/v1"), clientCore.Endpoint); + + client?.Dispose(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ItAddOrganizationHeaderWhenProvidedAsync(bool organizationIdProvided) + { + using HttpMessageHandlerStub handler = new(); + using HttpClient client = new(handler); + handler.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK); + + // Act + var clientCore = new ClientCore( + modelId: "model", + apiKey: "test", + organizationId: (organizationIdProvided) ? "organization" : null, + httpClient: client); + + var pipelineMessage = clientCore.Client.Pipeline.CreateMessage(); + pipelineMessage.Request.Method = "POST"; + pipelineMessage.Request.Uri = new Uri("http://localhost"); + pipelineMessage.Request.Content = BinaryContent.Create(new BinaryData("test")); + + // Assert + await clientCore.Client.Pipeline.SendAsync(pipelineMessage); + + if (organizationIdProvided) + { + Assert.True(handler.RequestHeaders!.Contains("OpenAI-Organization")); + Assert.Equal("organization", handler.RequestHeaders.GetValues("OpenAI-Organization").FirstOrDefault()); + } + else + { + Assert.False(handler.RequestHeaders!.Contains("OpenAI-Organization")); + } + } + + [Fact] + public async Task ItAddSemanticKernelHeadersOnEachRequestAsync() + { + using HttpMessageHandlerStub handler = new(); + using HttpClient client = new(handler); + handler.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK); + + // Act + var clientCore = new ClientCore(modelId: "model", apiKey: "test", httpClient: client); + + var pipelineMessage = clientCore.Client.Pipeline.CreateMessage(); + pipelineMessage.Request.Method = "POST"; + pipelineMessage.Request.Uri = new Uri("http://localhost"); + pipelineMessage.Request.Content = BinaryContent.Create(new BinaryData("test")); + + // Assert + await clientCore.Client.Pipeline.SendAsync(pipelineMessage); + + Assert.True(handler.RequestHeaders!.Contains(HttpHeaderConstant.Names.SemanticKernelVersion)); + Assert.Equal(HttpHeaderConstant.Values.GetAssemblyVersion(typeof(ClientCore)), handler.RequestHeaders.GetValues(HttpHeaderConstant.Names.SemanticKernelVersion).FirstOrDefault()); + + Assert.True(handler.RequestHeaders.Contains("User-Agent")); + Assert.Contains(HttpHeaderConstant.Values.UserAgent, handler.RequestHeaders.GetValues("User-Agent").FirstOrDefault()); + } + + [Fact] + public async Task ItDoNotAddSemanticKernelHeadersWhenOpenAIClientIsProvidedAsync() + { + using HttpMessageHandlerStub handler = new(); + using HttpClient client = new(handler); + handler.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK); + + // Act + var clientCore = new ClientCore( + modelId: "model", + openAIClient: new OpenAIClient( + new ApiKeyCredential("test"), + new OpenAIClientOptions() + { + Transport = new HttpClientPipelineTransport(client), + RetryPolicy = new ClientRetryPolicy(maxRetries: 0), + NetworkTimeout = Timeout.InfiniteTimeSpan + })); + + var pipelineMessage = clientCore.Client.Pipeline.CreateMessage(); + pipelineMessage.Request.Method = "POST"; + pipelineMessage.Request.Uri = new Uri("http://localhost"); + pipelineMessage.Request.Content = BinaryContent.Create(new BinaryData("test")); + + // Assert + await clientCore.Client.Pipeline.SendAsync(pipelineMessage); + + Assert.False(handler.RequestHeaders!.Contains(HttpHeaderConstant.Names.SemanticKernelVersion)); + Assert.DoesNotContain(HttpHeaderConstant.Values.UserAgent, handler.RequestHeaders.GetValues("User-Agent").FirstOrDefault()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("value")] + public void ItAddAttributesButDoesNothingIfNullOrEmpty(string? value) + { + // Arrange + var clientCore = new ClientCore("model", "apikey"); + // Act + + clientCore.AddAttribute("key", value); + + // Assert + if (string.IsNullOrEmpty(value)) + { + Assert.False(clientCore.Attributes.ContainsKey("key")); + } + else + { + Assert.True(clientCore.Attributes.ContainsKey("key")); + Assert.Equal(value, clientCore.Attributes["key"]); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/Models/AddHeaderRequestPolicyTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/Models/AddHeaderRequestPolicyTests.cs new file mode 100644 index 000000000000..83ec6a20568d --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/Models/AddHeaderRequestPolicyTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Core.Models; + +public class AddHeaderRequestPolicyTests +{ + [Fact] + public void ItCanBeInstantiated() + { + // Arrange + var headerName = "headerName"; + var headerValue = "headerValue"; + + // Act + var addHeaderRequestPolicy = new AddHeaderRequestPolicy(headerName, headerValue); + + // Assert + Assert.NotNull(addHeaderRequestPolicy); + } + + [Fact] + public void ItOnSendingRequestAddsHeaderToRequest() + { + // Arrange + var headerName = "headerName"; + var headerValue = "headerValue"; + var addHeaderRequestPolicy = new AddHeaderRequestPolicy(headerName, headerValue); + var pipeline = ClientPipeline.Create(); + var message = pipeline.CreateMessage(); + + // Act + addHeaderRequestPolicy.OnSendingRequest(message); + + // Assert + message.Request.Headers.TryGetValue(headerName, out var value); + Assert.NotNull(value); + Assert.Equal(headerValue, value); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/Models/PipelineSynchronousPolicyTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/Models/PipelineSynchronousPolicyTests.cs new file mode 100644 index 000000000000..cae4b32b4283 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/Models/PipelineSynchronousPolicyTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Core.Models; +public class PipelineSynchronousPolicyTests +{ + [Fact] + public async Task ItProcessAsyncWhenSpecializationHasReceivedResponseOverrideShouldCallIt() + { + // Arrange + var first = new MyHttpPipelinePolicyWithoutOverride(); + var last = new MyHttpPipelinePolicyWithOverride(); + + IReadOnlyList policies = [first, last]; + + // Act + await policies[0].ProcessAsync(ClientPipeline.Create().CreateMessage(), policies, 0); + + // Assert + Assert.True(first.CalledProcess); + Assert.True(last.CalledProcess); + Assert.True(last.CalledOnReceivedResponse); + } + + private class MyHttpPipelinePolicyWithoutOverride : PipelineSynchronousPolicy + { + public bool CalledProcess { get; private set; } + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.CalledProcess = true; + base.Process(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.CalledProcess = true; + return base.ProcessAsync(message, pipeline, currentIndex); + } + } + + private sealed class MyHttpPipelinePolicyWithOverride : MyHttpPipelinePolicyWithoutOverride + { + public bool CalledOnReceivedResponse { get; private set; } + + public override void OnReceivedResponse(PipelineMessage message) + { + this.CalledOnReceivedResponse = true; + } + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs new file mode 100644 index 000000000000..0b95f904d893 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.ClientModel.Primitives; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; + +public class ClientResultExceptionExtensionsTests +{ + [Fact] + public void ItCanRecoverFromResponseErrorAndConvertsToHttpOperationExceptionWithDefaultData() + { + // Arrange + var exception = new ClientResultException("message", ClientPipeline.Create().CreateMessage().Response); + + // Act + var httpOperationException = exception.ToHttpOperationException(); + + // Assert + Assert.NotNull(httpOperationException); + Assert.Equal(exception, httpOperationException.InnerException); + Assert.Equal(exception.Message, httpOperationException.Message); + Assert.Null(httpOperationException.ResponseContent); + Assert.Null(httpOperationException.StatusCode); + } + + [Fact] + public void ItCanProvideResponseContentAndStatusCode() + { + // Arrange + using var pipelineResponse = new MockPipelineResponse(); + + pipelineResponse.SetContent("content"); + pipelineResponse.SetStatus(200); + + var exception = new ClientResultException("message", pipelineResponse); + + // Act + var httpOperationException = exception.ToHttpOperationException(); + + // Assert + Assert.NotNull(httpOperationException); + Assert.NotNull(httpOperationException.StatusCode); + Assert.Equal(exception, httpOperationException.InnerException); + Assert.Equal(exception.Message, httpOperationException.Message); + Assert.Equal(pipelineResponse.Content.ToString(), httpOperationException.ResponseContent); + Assert.Equal(pipelineResponse.Status, (int)httpOperationException.StatusCode!); + } + + [Fact] + public void ItProvideStatusForResponsesWithoutContent() + { + // Arrange + using var pipelineResponse = new MockPipelineResponse(); + + pipelineResponse.SetStatus(200); + + var exception = new ClientResultException("message", pipelineResponse); + + // Act + var httpOperationException = exception.ToHttpOperationException(); + + // Assert + Assert.NotNull(httpOperationException); + Assert.NotNull(httpOperationException.StatusCode); + Assert.Empty(httpOperationException.ResponseContent!); + Assert.Equal(exception, httpOperationException.InnerException); + Assert.Equal(exception.Message, httpOperationException.Message); + Assert.Equal(pipelineResponse.Status, (int)httpOperationException.StatusCode!); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs new file mode 100644 index 000000000000..25cdc4ec61aa --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.Services; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; +public class OpenAITextEmbeddingGenerationServiceTests +{ + [Fact] + public void ItCanBeInstantiatedAndPropertiesSetAsExpected() + { + // Arrange + var sut = new OpenAITextEmbeddingGenerationService("model", "apiKey", dimensions: 2); + var sutWithOpenAIClient = new OpenAITextEmbeddingGenerationService("model", new OpenAIClient(new ApiKeyCredential("apiKey")), dimensions: 2); + + // Assert + Assert.NotNull(sut); + Assert.NotNull(sutWithOpenAIClient); + Assert.Equal("model", sut.Attributes[AIServiceExtensions.ModelIdKey]); + Assert.Equal("model", sutWithOpenAIClient.Attributes[AIServiceExtensions.ModelIdKey]); + } + + [Fact] + public async Task ItGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsEmpty() + { + // Arrange + var sut = new OpenAITextEmbeddingGenerationService("model", "apikey"); + + // Act + var result = await sut.GenerateEmbeddingsAsync([], null, CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task IGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsWhitespace() + { + using HttpMessageHandlerStub handler = new() + { + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("./TestData/text-embeddings-response.txt")) + } + }; + using HttpClient client = new(handler); + + // Arrange + var sut = new OpenAITextEmbeddingGenerationService("model", "apikey", httpClient: client); + + // Act + var result = await sut.GenerateEmbeddingsAsync(["test"], null, CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal(4, result[0].Length); + } + + [Fact] + public async Task ItThrowsIfNumberOfResultsDiffersFromInputsAsync() + { + using HttpMessageHandlerStub handler = new() + { + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("./TestData/text-embeddings-multiple-response.txt")) + } + }; + 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)); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-embeddings-multiple-response.txt b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-embeddings-multiple-response.txt new file mode 100644 index 000000000000..46a9581cf0cc --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-embeddings-multiple-response.txt @@ -0,0 +1,20 @@ +{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "zcyMP83MDEAzM1NAzcyMQA==" + }, + { + "object": "embedding", + "index": 1, + "embedding": "zcyMP83MDEAzM1NAzcyMQA==" + } + ], + "model": "text-embedding-ada-002", + "usage": { + "prompt_tokens": 7, + "total_tokens": 7 + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-embeddings-response.txt b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-embeddings-response.txt new file mode 100644 index 000000000000..c715b851b78c --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/TestData/text-embeddings-response.txt @@ -0,0 +1,15 @@ +{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "zcyMP83MDEAzM1NAzcyMQA==" + } + ], + "model": "text-embedding-ada-002", + "usage": { + "prompt_tokens": 7, + "total_tokens": 7 + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockPipelineResponse.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockPipelineResponse.cs new file mode 100644 index 000000000000..6fe18b9c1684 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockPipelineResponse.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* Phase 01 +This class was imported and adapted from the System.ClientModel Unit Tests. +https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockPipelineResponse.cs +*/ + +using System; +using System.ClientModel.Primitives; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests; + +public class MockPipelineResponse : PipelineResponse +{ + private int _status; + private string _reasonPhrase; + private Stream? _contentStream; + private BinaryData? _bufferedContent; + + private readonly PipelineResponseHeaders _headers; + + private bool _disposed; + + public MockPipelineResponse(int status = 0, string reasonPhrase = "") + { + this._status = status; + this._reasonPhrase = reasonPhrase; + this._headers = new MockResponseHeaders(); + } + + public override int Status => this._status; + + public void SetStatus(int value) => this._status = value; + + public override string ReasonPhrase => this._reasonPhrase; + + public void SetReasonPhrase(string value) => this._reasonPhrase = value; + + public void SetContent(byte[] content) + { + this.ContentStream = new MemoryStream(content, 0, content.Length, false, true); + } + + public MockPipelineResponse SetContent(string content) + { + this.SetContent(Encoding.UTF8.GetBytes(content)); + return this; + } + + public override Stream? ContentStream + { + get => this._contentStream; + set => this._contentStream = value; + } + + public override BinaryData Content + { + get + { + if (this._contentStream is null) + { + return new BinaryData(Array.Empty()); + } + + if (this.ContentStream is not MemoryStream memoryContent) + { + throw new InvalidOperationException("The response is not buffered."); + } + + if (memoryContent.TryGetBuffer(out ArraySegment segment)) + { + return new BinaryData(segment.AsMemory()); + } + return new BinaryData(memoryContent.ToArray()); + } + } + + protected override PipelineResponseHeaders HeadersCore + => this._headers; + + public sealed override void Dispose() + { + this.Dispose(true); + + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (disposing && !this._disposed) + { + Stream? content = this._contentStream; + if (content != null) + { + this._contentStream = null; + content.Dispose(); + } + + this._disposed = true; + } + } + + public override BinaryData BufferContent(CancellationToken cancellationToken = default) + { + if (this._bufferedContent is not null) + { + return this._bufferedContent; + } + + if (this._contentStream is null) + { + this._bufferedContent = new BinaryData(Array.Empty()); + return this._bufferedContent; + } + + MemoryStream bufferStream = new(); + this._contentStream.CopyTo(bufferStream); + this._contentStream.Dispose(); + this._contentStream = bufferStream; + + // Less efficient FromStream method called here because it is a mock. + // For intended production implementation, see HttpClientTransportResponse. + this._bufferedContent = BinaryData.FromStream(bufferStream); + return this._bufferedContent; + } + + public override async ValueTask BufferContentAsync(CancellationToken cancellationToken = default) + { + if (this._bufferedContent is not null) + { + return this._bufferedContent; + } + + if (this._contentStream is null) + { + this._bufferedContent = new BinaryData(Array.Empty()); + return this._bufferedContent; + } + + MemoryStream bufferStream = new(); + + await this._contentStream.CopyToAsync(bufferStream, cancellationToken).ConfigureAwait(false); + await this._contentStream.DisposeAsync().ConfigureAwait(false); + + this._contentStream = bufferStream; + + // Less efficient FromStream method called here because it is a mock. + // For intended production implementation, see HttpClientTransportResponse. + this._bufferedContent = BinaryData.FromStream(bufferStream); + return this._bufferedContent; + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockResponseHeaders.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockResponseHeaders.cs new file mode 100644 index 000000000000..fceef64e4bae --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Utils/MockResponseHeaders.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* Phase 01 +This class was imported and adapted from the System.ClientModel Unit Tests. +https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockResponseHeaders.cs +*/ + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests; + +public class MockResponseHeaders : PipelineResponseHeaders +{ + private readonly Dictionary _headers; + + public MockResponseHeaders() + { + this._headers = new Dictionary(); + } + + public override IEnumerator> GetEnumerator() + { + throw new NotImplementedException(); + } + + public override bool TryGetValue(string name, out string? value) + { + return this._headers.TryGetValue(name, out value); + } + + public override bool TryGetValues(string name, out IEnumerable? values) + { + throw new NotImplementedException(); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj b/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj index d5e129765dc9..b17b14eb91ef 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj @@ -30,5 +30,6 @@ + diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs new file mode 100644 index 000000000000..d11e2799addd --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.Embeddings.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* +Phase 01 + +This class was created to simplify any Text Embeddings Support from the v1 ClientCore +*/ + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Embeddings; + +#pragma warning disable CA2208 // Instantiate argument exceptions correctly + +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 embedding from the given . + /// + /// List of strings to generate embeddings for + /// The containing services, plugins, and other state for use throughout the operation. + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The to monitor for cancellation requests. The default is . + /// List of embeddings + internal async Task>> GetEmbeddingsAsync( + IList data, + Kernel? kernel, + int? dimensions, + CancellationToken cancellationToken) + { + var result = new List>(data.Count); + + if (data.Count > 0) + { + var embeddingsOptions = new EmbeddingGenerationOptions() + { + Dimensions = dimensions + }; + + ClientResult response = await RunRequestAsync(() => this.Client.GetEmbeddingClient(this.ModelId).GenerateEmbeddingsAsync(data, embeddingsOptions, cancellationToken)).ConfigureAwait(false); + var embeddings = response.Value; + + if (embeddings.Count != data.Count) + { + throw new KernelException($"Expected {data.Count} text embedding(s), but received {embeddings.Count}"); + } + + for (var i = 0; i < embeddings.Count; i++) + { + result.Add(embeddings[i].Vector); + } + } + + return result; + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs new file mode 100644 index 000000000000..12ca2f3d92fe --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* +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. +*/ + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Http; +using OpenAI; + +#pragma warning disable CA2208 // Instantiate argument exceptions correctly + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Base class for AI clients that provides common functionality for interacting with OpenAI services. +/// +internal partial class ClientCore +{ + /// + /// Default OpenAI API endpoint. + /// + private const string OpenAIV1Endpoint = "https://api.openai.com/v1"; + + /// + /// Identifier of the default model to use + /// + internal string ModelId { get; init; } = string.Empty; + + /// + /// Non-default endpoint for OpenAI API. + /// + internal Uri? Endpoint { get; init; } + + /// + /// Logger instance + /// + internal ILogger Logger { get; init; } + + /// + /// OpenAI / Azure OpenAI Client + /// + internal OpenAIClient Client { get; } + + /// + /// Storage for AI service attributes. + /// + internal Dictionary Attributes { get; } = []; + + /// + /// Initializes a new instance of the class. + /// + /// Model name. + /// OpenAI API Key. + /// OpenAI compatible API endpoint. + /// OpenAI Organization Id (usually optional). + /// 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, + HttpClient? httpClient = null, + ILogger? logger = null) + { + Verify.NotNullOrWhiteSpace(modelId); + + this.Logger = logger ?? NullLogger.Instance; + this.ModelId = modelId; + + // Accepts the endpoint if provided, otherwise uses the default OpenAI endpoint. + this.Endpoint = endpoint ?? httpClient?.BaseAddress; + if (this.Endpoint is null) + { + Verify.NotNullOrWhiteSpace(apiKey); // For Public OpenAI Endpoint a key must be provided. + this.Endpoint = new Uri(OpenAIV1Endpoint); + } + + var options = GetOpenAIClientOptions(httpClient, this.Endpoint); + if (!string.IsNullOrWhiteSpace(organizationId)) + { + options.AddPolicy(new AddHeaderRequestPolicy("OpenAI-Organization", organizationId!), PipelinePosition.PerCall); + } + + this.Client = new OpenAIClient(apiKey ?? string.Empty, options); + } + + /// + /// Initializes a new instance of the class using the specified OpenAIClient. + /// Note: instances created this way might not have the default diagnostics settings, + /// it's up to the caller to configure the client. + /// + /// Azure OpenAI model ID or deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Custom . + /// The to use for logging. If null, no logging will be performed. + internal ClientCore( + string modelId, + OpenAIClient openAIClient, + ILogger? logger = null) + { + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNull(openAIClient); + + this.Logger = logger ?? NullLogger.Instance; + this.ModelId = modelId; + this.Client = openAIClient; + } + + /// + /// Logs OpenAI action details. + /// + /// Caller member name. Populated automatically by runtime. + internal void LogActionDetails([CallerMemberName] string? callerMemberName = default) + { + if (this.Logger.IsEnabled(LogLevel.Information)) + { + this.Logger.LogInformation("Action: {Action}. OpenAI Model ID: {ModelId}.", callerMemberName, this.ModelId); + } + } + + /// + /// Allows adding attributes to the client. + /// + /// Attribute key. + /// Attribute value. + internal void AddAttribute(string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + this.Attributes.Add(key, value); + } + } + + /// Gets options to use for an OpenAIClient + /// Custom for HTTP requests. + /// Endpoint for the OpenAI API. + /// An instance of . + private static OpenAIClientOptions GetOpenAIClientOptions(HttpClient? httpClient, Uri? endpoint) + { + OpenAIClientOptions options = new() + { + ApplicationId = HttpHeaderConstant.Values.UserAgent, + Endpoint = endpoint + }; + + options.AddPolicy(new AddHeaderRequestPolicy(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(ClientCore))), PipelinePosition.PerCall); + + if (httpClient is not null) + { + options.Transport = new HttpClientPipelineTransport(httpClient); + options.RetryPolicy = new ClientRetryPolicy(maxRetries: 0); // Disable retry policy if and only if a custom HttpClient is provided. + options.NetworkTimeout = Timeout.InfiniteTimeSpan; // Disable default timeout + } + + return options; + } + + /// + /// Invokes the specified request and handles exceptions. + /// + /// Type of the response. + /// Request to invoke. + /// Returns the response. + private static async Task RunRequestAsync(Func> request) + { + try + { + return await request.Invoke().ConfigureAwait(false); + } + catch (ClientResultException e) + { + throw e.ToHttpOperationException(); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/Models/AddHeaderRequestPolicy.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/Models/AddHeaderRequestPolicy.cs new file mode 100644 index 000000000000..2279d639c54e --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/Models/AddHeaderRequestPolicy.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* Phase 1 +Added from OpenAI v1 with adapted logic to the System.ClientModel abstraction +*/ + +using System.ClientModel.Primitives; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Helper class to inject headers into System ClientModel Http pipeline +/// +internal sealed class AddHeaderRequestPolicy(string headerName, string headerValue) : PipelineSynchronousPolicy +{ + private readonly string _headerName = headerName; + private readonly string _headerValue = headerValue; + + public override void OnSendingRequest(PipelineMessage message) + { + message.Request.Headers.Add(this._headerName, this._headerValue); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/Models/PipelineSynchronousPolicy.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/Models/PipelineSynchronousPolicy.cs new file mode 100644 index 000000000000..b7690ead8b7f --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/Models/PipelineSynchronousPolicy.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* +Phase 1 +As SystemClient model does not have any specialization or extension ATM, introduced this class with the adapted to use System.ClientModel abstractions. +https://github.com/Azure/azure-sdk-for-net/blob/8bd22837639d54acccc820e988747f8d28bbde4a/sdk/core/Azure.Core/src/Pipeline/HttpPipelineSynchronousPolicy.cs +*/ + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Represents a that doesn't do any asynchronous or synchronously blocking operations. +/// +internal class PipelineSynchronousPolicy : PipelinePolicy +{ + private static readonly Type[] s_onReceivedResponseParameters = new[] { typeof(PipelineMessage) }; + + private readonly bool _hasOnReceivedResponse = true; + + /// + /// Initializes a new instance of + /// + protected PipelineSynchronousPolicy() + { + var onReceivedResponseMethod = this.GetType().GetMethod(nameof(OnReceivedResponse), BindingFlags.Instance | BindingFlags.Public, null, s_onReceivedResponseParameters, null); + if (onReceivedResponseMethod != null) + { + this._hasOnReceivedResponse = onReceivedResponseMethod.GetBaseDefinition().DeclaringType != onReceivedResponseMethod.DeclaringType; + } + } + + /// + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.OnSendingRequest(message); + if (pipeline.Count > currentIndex + 1) + { + // If there are more policies in the pipeline, continue processing + ProcessNext(message, pipeline, currentIndex); + } + this.OnReceivedResponse(message); + } + + /// + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + if (!this._hasOnReceivedResponse) + { + // If OnReceivedResponse was not overridden we can avoid creating a state machine and return the task directly + this.OnSendingRequest(message); + if (pipeline.Count > currentIndex + 1) + { + // If there are more policies in the pipeline, continue processing + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + + return this.InnerProcessAsync(message, pipeline, currentIndex); + } + + private async ValueTask InnerProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.OnSendingRequest(message); + if (pipeline.Count > currentIndex + 1) + { + // If there are more policies in the pipeline, continue processing + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + this.OnReceivedResponse(message); + } + + /// + /// Method is invoked before the request is sent. + /// + /// The containing the request. + public virtual void OnSendingRequest(PipelineMessage message) { } + + /// + /// Method is invoked after the response is received. + /// + /// The containing the response. + public virtual void OnReceivedResponse(PipelineMessage message) { } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/ClientResultExceptionExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/ClientResultExceptionExtensions.cs new file mode 100644 index 000000000000..7da92e5826ba --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/ClientResultExceptionExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* +Phase 01: +This class is introduced in exchange for the original RequestExceptionExtensions class of Azure.Core to the new ClientException from System.ClientModel, +Preserved the logic as is. +*/ + +using System.ClientModel; +using System.Net; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Provides extension methods for the class. +/// +internal static class ClientResultExceptionExtensions +{ + /// + /// Converts a to an . + /// + /// The original . + /// An instance. + public static HttpOperationException ToHttpOperationException(this ClientResultException exception) + { + const int NoResponseReceived = 0; + + string? responseContent = null; + + try + { + responseContent = exception.GetRawResponse()?.Content.ToString(); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch { } // We want to suppress any exceptions that occur while reading the content, ensuring that an HttpOperationException is thrown instead. +#pragma warning restore CA1031 + + return new HttpOperationException( + exception.Status == NoResponseReceived ? null : (HttpStatusCode?)exception.Status, + responseContent, + exception.Message, + exception); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs new file mode 100644 index 000000000000..49915031b7fc --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs @@ -0,0 +1,85 @@ +// 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.Embeddings; +using Microsoft.SemanticKernel.Services; +using OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// OpenAI implementation of +/// +[Experimental("SKEXP0010")] +public sealed class OpenAITextEmbeddingGenerationService : ITextEmbeddingGenerationService +{ + private readonly ClientCore _core; + private readonly int? _dimensions; + + /// + /// Create an instance of + /// + /// Model name + /// OpenAI API Key + /// OpenAI Organization Id (usually optional) + /// 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. + public OpenAITextEmbeddingGenerationService( + string modelId, + string apiKey, + string? organization = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null, + int? dimensions = null) + { + this._core = new( + modelId: modelId, + apiKey: apiKey, + organizationId: organization, + httpClient: httpClient, + logger: loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); + + this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + + this._dimensions = dimensions; + } + + /// + /// Create an instance of the OpenAI text embedding connector + /// + /// Model name + /// 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. + public OpenAITextEmbeddingGenerationService( + string modelId, + OpenAIClient openAIClient, + ILoggerFactory? loggerFactory = null, + int? dimensions = null) + { + this._core = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); + this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + + this._dimensions = dimensions; + } + + /// + public IReadOnlyDictionary Attributes => this._core.Attributes; + + /// + public Task>> GenerateEmbeddingsAsync( + IList data, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + this._core.LogActionDetails(); + return this._core.GetEmbeddingsAsync(data, kernel, this._dimensions, cancellationToken); + } +} diff --git a/dotnet/src/IntegrationTestsV2/.editorconfig b/dotnet/src/IntegrationTestsV2/.editorconfig new file mode 100644 index 000000000000..394eef685f21 --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/.editorconfig @@ -0,0 +1,6 @@ +# Suppressing errors for Test projects under dotnet folder +[*.cs] +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations diff --git a/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs new file mode 100644 index 000000000000..6eca1909a546 --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/Connectors/OpenAI/OpenAITextEmbeddingTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.Embeddings; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; + +public sealed class OpenAITextEmbeddingTests +{ + private const int AdaVectorLength = 1536; + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + [Theory]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + [InlineData("test sentence")] + public async Task OpenAITestAsync(string testInputString) + { + // Arrange + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("OpenAIEmbeddings").Get(); + Assert.NotNull(openAIConfiguration); + + var embeddingGenerator = new OpenAITextEmbeddingGenerationService(openAIConfiguration.ModelId, openAIConfiguration.ApiKey); + + // Act + var singleResult = await embeddingGenerator.GenerateEmbeddingAsync(testInputString); + var batchResult = await embeddingGenerator.GenerateEmbeddingsAsync([testInputString, testInputString, testInputString]); + + // Assert + Assert.Equal(AdaVectorLength, singleResult.Length); + Assert.Equal(3, batchResult.Count); + } + + [Theory]//(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) + { + // Arrange + const string TestInputString = "test sentence"; + + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("OpenAIEmbeddings").Get(); + Assert.NotNull(openAIConfiguration); + + var embeddingGenerator = new OpenAITextEmbeddingGenerationService( + "text-embedding-3-large", + openAIConfiguration.ApiKey, + dimensions: dimensions); + + // Act + var result = await embeddingGenerator.GenerateEmbeddingAsync(TestInputString); + + // Assert + Assert.Equal(expectedVectorLength, result.Length); + } +} diff --git a/dotnet/src/IntegrationTestsV2/IntegrationTestsV2.csproj b/dotnet/src/IntegrationTestsV2/IntegrationTestsV2.csproj index cbfbfe9e4df3..f3c704a27307 100644 --- a/dotnet/src/IntegrationTestsV2/IntegrationTestsV2.csproj +++ b/dotnet/src/IntegrationTestsV2/IntegrationTestsV2.csproj @@ -1,7 +1,7 @@ - + IntegrationTests - SemanticKernel.IntegrationTests + SemanticKernel.IntegrationTestsV2 net8.0 true false @@ -16,7 +16,7 @@ - + @@ -44,7 +44,6 @@ - @@ -64,4 +63,5 @@ Always + \ No newline at end of file diff --git a/dotnet/src/IntegrationTestsV2/TestSettings/OpenAIConfiguration.cs b/dotnet/src/IntegrationTestsV2/TestSettings/OpenAIConfiguration.cs new file mode 100644 index 000000000000..cb3884e3bdfc --- /dev/null +++ b/dotnet/src/IntegrationTestsV2/TestSettings/OpenAIConfiguration.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace SemanticKernel.IntegrationTests.TestSettings; + +[SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated", + Justification = "Configuration classes are instantiated through IConfiguration.")] +internal sealed class OpenAIConfiguration(string serviceId, string modelId, string apiKey, string? chatModelId = null) +{ + public string ServiceId { get; set; } = serviceId; + public string ModelId { get; set; } = modelId; + public string? ChatModelId { get; set; } = chatModelId; + public string ApiKey { get; set; } = apiKey; +}