diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs index f162e1d7334c..b6783adc4823 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Core/ClientCoreTests.cs @@ -222,15 +222,6 @@ public void ItAddOrNotOrganizationIdAttributeWhenProvided() Assert.False(clientCoreWithoutOrgId.Attributes.ContainsKey(ClientCore.OrganizationKey)); } - [Fact] - public void ItThrowsIfModelIdIsNotProvided() - { - // Act & Assert - Assert.Throws(() => new ClientCore(" ", "apikey")); - Assert.Throws(() => new ClientCore("", "apikey")); - Assert.Throws(() => new ClientCore(null!)); - } - [Fact] public void ItThrowsWhenNotUsingCustomEndpointAndApiKeyIsNotProvided() { diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs index bfa71f7e5ab3..6068dbe558da 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs @@ -2,6 +2,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.AudioToText; +using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Services; using Microsoft.SemanticKernel.TextToAudio; @@ -132,4 +133,15 @@ public void ItCanAddAudioToTextServiceWithOpenAIClient() // Assert Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); } + + [Fact] + public void ItCanAddFileService() + { + // Arrange + var sut = Kernel.CreateBuilder(); + + // Act + var service = sut.AddOpenAIFiles("key").Build() + .GetRequiredService(); + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/OpenAIFileUploadExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/OpenAIFileUploadExecutionSettingsTests.cs new file mode 100644 index 000000000000..8e4ffa622ca8 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/OpenAIFileUploadExecutionSettingsTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; + +public class OpenAIFileUploadExecutionSettingsTests +{ + [Fact] + public void ItCanCreateOpenAIFileUploadExecutionSettings() + { + // Arrange + var fileName = "file.txt"; + var purpose = OpenAIFilePurpose.FineTune; + + // Act + var settings = new OpenAIFileUploadExecutionSettings(fileName, purpose); + + // Assert + Assert.Equal(fileName, settings.FileName); + Assert.Equal(purpose, settings.Purpose); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 79c8024bb93f..19c030b820fb 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.AudioToText; +using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Services; using Microsoft.SemanticKernel.TextToAudio; @@ -133,4 +134,16 @@ public void ItCanAddAudioToTextServiceWithOpenAIClient() // Assert Assert.Equal("model", service.Attributes[AIServiceExtensions.ModelIdKey]); } + + [Fact] + public void ItCanAddFileService() + { + // Arrange + var sut = new ServiceCollection(); + + // Act + var service = sut.AddOpenAIFiles("key") + .BuildServiceProvider() + .GetRequiredService(); + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs index 9648670d3de5..5627803bfab1 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIAudioToTextServiceTests.cs @@ -45,6 +45,18 @@ public void ConstructorWithApiKeyWorksCorrectly(bool includeLoggerFactory) Assert.Equal("model-id", service.Attributes["ModelId"]); } + [Fact] + public void ItThrowsIfModelIdIsNotProvided() + { + // Act & Assert + Assert.Throws(() => new OpenAIAudioToTextService(" ", "apikey")); + Assert.Throws(() => new OpenAIAudioToTextService(" ", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAIAudioToTextService("", "apikey")); + Assert.Throws(() => new OpenAIAudioToTextService("", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAIAudioToTextService(null!, "apikey")); + Assert.Throws(() => new OpenAIAudioToTextService(null!, openAIClient: new("apikey"))); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs new file mode 100644 index 000000000000..85ac2f2bf8d4 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +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 Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Unit tests for class. +/// +public sealed class OpenAIFileServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Mock _mockLoggerFactory; + + public OpenAIFileServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + this._mockLoggerFactory = new Mock(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWorksCorrectlyForOpenAI(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new OpenAIFileService("api-key", loggerFactory: this._mockLoggerFactory.Object) : + new OpenAIFileService("api-key"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWorksCorrectlyForAzure(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new OpenAIFileService(new Uri("http://localhost"), "api-key", loggerFactory: this._mockLoggerFactory.Object) : + new OpenAIFileService(new Uri("http://localhost"), "api-key"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeleteFileWorksCorrectlyAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateSuccessResponse( + """ + { + "id": "123", + "filename": "test.txt", + "purpose": "assistants", + "bytes": 120000, + "created_at": 1677610602 + } + """); + + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + await service.DeleteFileAsync("file-id"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeleteFileFailsAsExpectedAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateFailedResponse(); + + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + await Assert.ThrowsAsync(() => service.DeleteFileAsync("file-id")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetFileWorksCorrectlyAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateSuccessResponse( + """ + { + "id": "123", + "filename": "file.txt", + "purpose": "assistants", + "bytes": 120000, + "created_at": 1677610602 + } + """); + + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + var file = await service.GetFileAsync("file-id"); + Assert.NotNull(file); + Assert.NotEqual(string.Empty, file.Id); + Assert.NotEqual(string.Empty, file.FileName); + Assert.NotEqual(DateTime.MinValue, file.CreatedTimestamp); + Assert.NotEqual(0, file.SizeInBytes); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetFileFailsAsExpectedAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateFailedResponse(); + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + await Assert.ThrowsAsync(() => service.GetFileAsync("file-id")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetFilesWorksCorrectlyAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateSuccessResponse( + """ + { + "data": [ + { + "id": "123", + "filename": "file1.txt", + "purpose": "assistants", + "bytes": 120000, + "created_at": 1677610602 + }, + { + "id": "456", + "filename": "file2.txt", + "purpose": "assistants", + "bytes": 999, + "created_at": 1677610606 + } + ] + } + """); + + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + var files = (await service.GetFilesAsync()).ToArray(); + Assert.NotNull(files); + Assert.NotEmpty(files); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetFilesFailsAsExpectedAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateFailedResponse(); + + this._messageHandlerStub.ResponseToReturn = response; + + await Assert.ThrowsAsync(() => service.GetFilesAsync()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetFileContentWorksCorrectlyAsync(bool isCustomEndpoint) + { + // Arrange + var data = BinaryData.FromString("Hello AI!"); + var service = this.CreateFileService(isCustomEndpoint); + this._messageHandlerStub.ResponseToReturn = + new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new ByteArrayContent(data.ToArray()) + }; + + // Act & Assert + var content = await service.GetFileContentAsync("file-id"); + var result = content.Data!.Value; + Assert.Equal(data.ToArray(), result.ToArray()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UploadContentWorksCorrectlyAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateSuccessResponse( + """ + { + "id": "123", + "filename": "test.txt", + "purpose": "assistants", + "bytes": 120000, + "created_at": 1677610602 + } + """); + + this._messageHandlerStub.ResponseToReturn = response; + + var settings = new OpenAIFileUploadExecutionSettings("test.txt", OpenAIFilePurpose.Assistants); + + await using var stream = new MemoryStream(); + await using (var writer = new StreamWriter(stream, leaveOpen: true)) + { + await writer.WriteLineAsync("test"); + await writer.FlushAsync(); + } + + stream.Position = 0; + + var content = new BinaryContent(stream.ToArray(), "text/plain"); + + // Act & Assert + var file = await service.UploadContentAsync(content, settings); + Assert.NotNull(file); + Assert.NotEqual(string.Empty, file.Id); + Assert.NotEqual(string.Empty, file.FileName); + Assert.NotEqual(DateTime.MinValue, file.CreatedTimestamp); + Assert.NotEqual(0, file.SizeInBytes); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UploadContentFailsAsExpectedAsync(bool isCustomEndpoint) + { + // Arrange + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateFailedResponse(); + + this._messageHandlerStub.ResponseToReturn = response; + + var settings = new OpenAIFileUploadExecutionSettings("test.txt", OpenAIFilePurpose.Assistants); + + await using var stream = new MemoryStream(); + await using (var writer = new StreamWriter(stream, leaveOpen: true)) + { + await writer.WriteLineAsync("test"); + await writer.FlushAsync(); + } + + stream.Position = 0; + + var content = new BinaryContent(stream.ToArray(), "text/plain"); + + // Act & Assert + await Assert.ThrowsAsync(() => service.UploadContentAsync(content, settings)); + } + + private OpenAIFileService CreateFileService(bool isCustomEndpoint = false) + { + return + isCustomEndpoint ? + new OpenAIFileService(new Uri("http://localhost"), "api-key", httpClient: this._httpClient) : + new OpenAIFileService("api-key", "organization", this._httpClient); + } + + private HttpResponseMessage CreateSuccessResponse(string payload) + { + return + new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = + new StringContent( + payload, + Encoding.UTF8, + "application/json") + }; + } + + private HttpResponseMessage CreateFailedResponse(string? payload = null) + { + return + new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest) + { + Content = + string.IsNullOrEmpty(payload) ? + null : + new StringContent( + payload, + Encoding.UTF8, + "application/json") + }; + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs index 5fb36efc0349..0181d15d8449 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextEmbeddingGenerationServiceTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.ClientModel; using System.IO; using System.Net; @@ -35,6 +36,18 @@ public void ItCanBeInstantiatedAndPropertiesSetAsExpected() Assert.Equal("model", sutWithOpenAIClient.Attributes[AIServiceExtensions.ModelIdKey]); } + [Fact] + public void ItThrowsIfModelIdIsNotProvided() + { + // Act & Assert + Assert.Throws(() => new OpenAITextEmbeddingGenerationService(" ", "apikey")); + Assert.Throws(() => new OpenAITextEmbeddingGenerationService(" ", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAITextEmbeddingGenerationService("", "apikey")); + Assert.Throws(() => new OpenAITextEmbeddingGenerationService("", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAITextEmbeddingGenerationService(null!, "apikey")); + Assert.Throws(() => new OpenAITextEmbeddingGenerationService(null!, openAIClient: new("apikey"))); + } + [Fact] public async Task ItGetEmbeddingsAsyncReturnsEmptyWhenProvidedDataIsEmpty() { diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs index e8fdb7b46b1e..9c7de44d8a83 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToAudioServiceTests.cs @@ -45,6 +45,18 @@ public void ConstructorWithApiKeyWorksCorrectly(bool includeLoggerFactory) Assert.Equal("model-id", service.Attributes["ModelId"]); } + [Fact] + public void ItThrowsIfModelIdIsNotProvided() + { + // Act & Assert + Assert.Throws(() => new OpenAITextToAudioService(" ", "apikey")); + Assert.Throws(() => new OpenAITextToAudioService(" ", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAITextToAudioService("", "apikey")); + Assert.Throws(() => new OpenAITextToAudioService("", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAITextToAudioService(null!, "apikey")); + Assert.Throws(() => new OpenAITextToAudioService(null!, openAIClient: new("apikey"))); + } + [Theory] [MemberData(nameof(ExecutionSettings))] public async Task GetAudioContentWithInvalidSettingsThrowsExceptionAsync(OpenAITextToAudioExecutionSettings? settings, Type expectedExceptionType) diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs index f449059e8ab5..c31c1f275dbc 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAITextToImageServiceTests.cs @@ -47,6 +47,18 @@ public void ConstructorWorksCorrectly() Assert.Equal("model", sut.Attributes[AIServiceExtensions.ModelIdKey]); } + [Fact] + public void ItThrowsIfModelIdIsNotProvided() + { + // Act & Assert + Assert.Throws(() => new OpenAITextToImageService(" ", "apikey")); + Assert.Throws(() => new OpenAITextToImageService(" ", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAITextToImageService("", "apikey")); + Assert.Throws(() => new OpenAITextToImageService("", openAIClient: new("apikey"))); + Assert.Throws(() => new OpenAITextToImageService(null!, "apikey")); + Assert.Throws(() => new OpenAITextToImageService(null!, openAIClient: new("apikey"))); + } + [Fact] public void OpenAIClientConstructorWorksCorrectly() { diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj b/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj index 22f364461818..bab4ac2c2e15 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Connectors.OpenAIV2.csproj @@ -17,8 +17,8 @@ - Semantic Kernel - OpenAI and Azure OpenAI connectors - Semantic Kernel connectors for OpenAI and Azure OpenAI. Contains clients for text generation, chat completion, embedding and DALL-E text to image. + Semantic Kernel - OpenAI connector + Semantic Kernel connectors for OpenAI. Contains clients for text generation, chat completion, embedding and DALL-E text to image. diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs new file mode 100644 index 000000000000..41a9f470c4b0 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +/* +Phase 05 +- Ignoring the specific Purposes not implemented by current FileService. +*/ + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Files; + +using OAIFilePurpose = OpenAI.Files.OpenAIFilePurpose; +using SKFilePurpose = Microsoft.SemanticKernel.Connectors.OpenAI.OpenAIFilePurpose; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Base class for AI clients that provides common functionality for interacting with OpenAI services. +/// +internal partial class ClientCore +{ + /// + /// Uploads a file to OpenAI. + /// + /// File name + /// File content + /// Purpose of the file + /// Cancellation token + /// Uploaded file information + internal async Task UploadFileAsync( + string fileName, + Stream fileContent, + SKFilePurpose purpose, + CancellationToken cancellationToken) + { + ClientResult response = await RunRequestAsync(() => this.Client.GetFileClient().UploadFileAsync(fileContent, fileName, ConvertToOpenAIFilePurpose(purpose), cancellationToken)).ConfigureAwait(false); + return ConvertToFileReference(response.Value); + } + + /// + /// Delete a previously uploaded file. + /// + /// The uploaded file identifier. + /// The to monitor for cancellation requests. The default is . + internal async Task DeleteFileAsync( + string fileId, + CancellationToken cancellationToken) + { + await RunRequestAsync(() => this.Client.GetFileClient().DeleteFileAsync(fileId, cancellationToken)).ConfigureAwait(false); + } + + /// + /// Retrieve metadata for a previously uploaded file. + /// + /// The uploaded file identifier. + /// The to monitor for cancellation requests. The default is . + /// The metadata associated with the specified file identifier. + internal async Task GetFileAsync( + string fileId, + CancellationToken cancellationToken) + { + ClientResult response = await RunRequestAsync(() => this.Client.GetFileClient().GetFileAsync(fileId, cancellationToken)).ConfigureAwait(false); + return ConvertToFileReference(response.Value); + } + + /// + /// Retrieve metadata for all previously uploaded files. + /// + /// The to monitor for cancellation requests. The default is . + /// The metadata of all uploaded files. + internal async Task> GetFilesAsync(CancellationToken cancellationToken) + { + ClientResult response = await RunRequestAsync(() => this.Client.GetFileClient().GetFilesAsync(cancellationToken: cancellationToken)).ConfigureAwait(false); + return response.Value.Select(ConvertToFileReference); + } + + /// + /// Retrieve the file content from a previously uploaded file. + /// + /// The uploaded file identifier. + /// The to monitor for cancellation requests. The default is . + /// The file content as + /// + /// Files uploaded with do not support content retrieval. + /// + internal async Task GetFileContentAsync( + string fileId, + CancellationToken cancellationToken) + { + ClientResult response = await RunRequestAsync(() => this.Client.GetFileClient().DownloadFileAsync(fileId, cancellationToken)).ConfigureAwait(false); + return response.Value.ToArray(); + } + + private static OpenAIFileReference ConvertToFileReference(OpenAIFileInfo fileInfo) + => new() + { + Id = fileInfo.Id, + CreatedTimestamp = fileInfo.CreatedAt.DateTime, + FileName = fileInfo.Filename, + SizeInBytes = (int)(fileInfo.SizeInBytes ?? 0), + Purpose = ConvertToFilePurpose(fileInfo.Purpose), + }; + + private static FileUploadPurpose ConvertToOpenAIFilePurpose(SKFilePurpose purpose) + { + if (purpose == SKFilePurpose.Assistants) { return FileUploadPurpose.Assistants; } + if (purpose == SKFilePurpose.FineTune) { return FileUploadPurpose.FineTune; } + + throw new KernelException($"Unknown {nameof(OpenAIFilePurpose)}: {purpose}."); + } + + private static SKFilePurpose ConvertToFilePurpose(OAIFilePurpose purpose) + { + if (purpose == OAIFilePurpose.Assistants) { return SKFilePurpose.Assistants; } + if (purpose == OAIFilePurpose.FineTune) { return SKFilePurpose.FineTune; } + + throw new KernelException($"Unknown {nameof(OpenAIFilePurpose)}: {purpose}."); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs index 355000887f51..695f23579ad1 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs @@ -9,6 +9,10 @@ All logic from original ClientCore and OpenAIClientCore were preserved. - Moved AddAttributes usage to the constructor, avoiding the need verify and adding it in the services. - Added ModelId attribute to the OpenAIClient constructor. - Added WhiteSpace instead of empty string for ApiKey to avoid exception from OpenAI Client on custom endpoints added an issue in OpenAI SDK repo. https://github.com/openai/openai-dotnet/issues/90 + +Phase 05: +- Model Id became not be required to support services like: File Service. + */ using System; @@ -65,7 +69,7 @@ internal partial class ClientCore internal ILogger Logger { get; init; } /// - /// OpenAI / Azure OpenAI Client + /// OpenAI Client /// internal OpenAIClient Client { get; } @@ -84,19 +88,20 @@ internal partial class ClientCore /// Custom for HTTP requests. /// The to use for logging. If null, no logging will be performed. internal ClientCore( - string modelId, + string? modelId = null, string? apiKey = null, string? organizationId = null, Uri? endpoint = null, HttpClient? httpClient = null, ILogger? logger = null) { - Verify.NotNullOrWhiteSpace(modelId); + if (!string.IsNullOrWhiteSpace(modelId)) + { + this.ModelId = modelId!; + this.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + } this.Logger = logger ?? NullLogger.Instance; - this.ModelId = modelId; - - this.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); // Accepts the endpoint if provided, otherwise uses the default OpenAI endpoint. this.Endpoint = endpoint ?? httpClient?.BaseAddress; @@ -129,22 +134,25 @@ internal ClientCore( /// 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 + /// OpenAI model Id /// Custom . /// The to use for logging. If null, no logging will be performed. internal ClientCore( - string modelId, + string? modelId, OpenAIClient openAIClient, ILogger? logger = null) { - Verify.NotNullOrWhiteSpace(modelId); + // Model Id may not be required when other services. i.e: File Service. + if (modelId is not null) + { + this.ModelId = modelId; + this.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + } + Verify.NotNull(openAIClient); this.Logger = logger ?? NullLogger.Instance; - this.ModelId = modelId; this.Client = openAIClient; - - this.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); } /// diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs index ce4a4d9866e0..37ac7d384647 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs @@ -287,4 +287,38 @@ OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => } #endregion + + #region Files + + /// + /// Add the OpenAI file service to the list + /// + /// The instance to augment. + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The HttpClient to use with this service. + /// The same instance as . + [Experimental("SKEXP0010")] + public static IKernelBuilder AddOpenAIFiles( + this IKernelBuilder builder, + string apiKey, + string? orgId = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + Verify.NotNullOrWhiteSpace(apiKey); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAIFileService( + apiKey, + orgId, + HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + serviceProvider.GetService())); + + return builder; + } + + #endregion } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs index 769634c1cea7..c1c2fe7dd2f7 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIServiceCollectionExtensions.cs @@ -266,4 +266,36 @@ OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => return services; } #endregion + + #region Files + + /// + /// Add the OpenAI file service to the list + /// + /// The instance to augment. + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The same instance as . + [Experimental("SKEXP0010")] + public static IServiceCollection AddOpenAIFiles( + this IServiceCollection services, + string apiKey, + string? orgId = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(apiKey); + + services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new OpenAIFileService( + apiKey, + orgId, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService())); + + return services; + } + + #endregion } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFilePurpose.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFilePurpose.cs new file mode 100644 index 000000000000..a01b2d08fa8d --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFilePurpose.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Defines the purpose associated with the uploaded file. +/// +[Experimental("SKEXP0010")] +public enum OpenAIFilePurpose +{ + /// + /// File to be used by assistants for model processing. + /// + Assistants, + + /// + /// File to be used by fine-tuning jobs. + /// + FineTune, +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFileReference.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFileReference.cs new file mode 100644 index 000000000000..371be0d93a33 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFileReference.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// References an uploaded file by id. +/// +[Experimental("SKEXP0010")] +public sealed class OpenAIFileReference +{ + /// + /// The file identifier. + /// + public string Id { get; set; } = string.Empty; + + /// + /// The timestamp the file was uploaded.s + /// + public DateTime CreatedTimestamp { get; set; } + + /// + /// The name of the file.s + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Describes the associated purpose of the file. + /// + public OpenAIFilePurpose Purpose { get; set; } + + /// + /// The file size, in bytes. + /// + public int SizeInBytes { get; set; } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs index cb37384845df..9084ab1782c3 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIAudioToTextService.cs @@ -49,6 +49,7 @@ public OpenAIAudioToTextService( HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, apiKey, organization, endpoint, httpClient, loggerFactory?.CreateLogger(typeof(OpenAIAudioToTextService))); } @@ -63,6 +64,7 @@ public OpenAIAudioToTextService( OpenAIClient openAIClient, ILoggerFactory? loggerFactory = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs new file mode 100644 index 000000000000..8b50df3f3639 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// File service access for OpenAI: https://api.openai.com/v1/files +/// +[Experimental("SKEXP0010")] +public sealed class OpenAIFileService +{ + /// + /// OpenAI client for HTTP operations. + /// + private readonly ClientCore _client; + + /// + /// Create an instance of the OpenAI chat completion connector + /// + /// Non-default endpoint for the OpenAI API. + /// API Key + /// OpenAI Organization Id (usually optional) + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public OpenAIFileService( + Uri endpoint, + string apiKey, + string? organization = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + Verify.NotNull(apiKey, nameof(apiKey)); + + this._client = new(null, apiKey, organization, endpoint, httpClient, loggerFactory?.CreateLogger(typeof(OpenAIFileService))); + } + + /// + /// Create an instance of the OpenAI chat completion connector + /// + /// OpenAI API Key + /// OpenAI Organization Id (usually optional) + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public OpenAIFileService( + string apiKey, + string? organization = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + Verify.NotNull(apiKey, nameof(apiKey)); + + this._client = new(null, apiKey, organization, null, httpClient, loggerFactory?.CreateLogger(typeof(OpenAIFileService))); + } + + /// + /// Remove a previously uploaded file. + /// + /// The uploaded file identifier. + /// The to monitor for cancellation requests. The default is . + public Task DeleteFileAsync(string id, CancellationToken cancellationToken = default) + { + Verify.NotNull(id, nameof(id)); + + return this._client.DeleteFileAsync(id, cancellationToken); + } + + /// + /// Retrieve the file content from a previously uploaded file. + /// + /// The uploaded file identifier. + /// The to monitor for cancellation requests. The default is . + /// The file content as + /// + /// Files uploaded with do not support content retrieval. + /// + public async Task GetFileContentAsync(string id, CancellationToken cancellationToken = default) + { + Verify.NotNull(id, nameof(id)); + var bytes = await this._client.GetFileContentAsync(id, cancellationToken).ConfigureAwait(false); + + // The mime type of the downloaded file is not provided by the OpenAI API. + return new(bytes, null); + } + + /// + /// Retrieve metadata for a previously uploaded file. + /// + /// The uploaded file identifier. + /// The to monitor for cancellation requests. The default is . + /// The metadata associated with the specified file identifier. + public Task GetFileAsync(string id, CancellationToken cancellationToken = default) + { + Verify.NotNull(id, nameof(id)); + return this._client.GetFileAsync(id, cancellationToken); + } + + /// + /// Retrieve metadata for all previously uploaded files. + /// + /// The to monitor for cancellation requests. The default is . + /// The metadata of all uploaded files. + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) + => await this._client.GetFilesAsync(cancellationToken).ConfigureAwait(false); + + /// + /// Upload a file. + /// + /// The file content as + /// The upload settings + /// The to monitor for cancellation requests. The default is . + /// The file metadata. + public async Task UploadContentAsync(BinaryContent fileContent, OpenAIFileUploadExecutionSettings settings, CancellationToken cancellationToken = default) + { + Verify.NotNull(settings, nameof(settings)); + Verify.NotNull(fileContent.Data, nameof(fileContent.Data)); + + using var memoryStream = new MemoryStream(fileContent.Data.Value.ToArray()); + return await this._client.UploadFileAsync(settings.FileName, memoryStream, settings.Purpose, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs index ea607b2565b3..39837bde1bc4 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextEmbbedingGenerationService.cs @@ -44,6 +44,7 @@ public OpenAITextEmbeddingGenerationService( ILoggerFactory? loggerFactory = null, int? dimensions = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new( modelId: modelId, apiKey: apiKey, @@ -68,6 +69,7 @@ public OpenAITextEmbeddingGenerationService( ILoggerFactory? loggerFactory = null, int? dimensions = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextEmbeddingGenerationService))); this._dimensions = dimensions; } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs index 87346eefb1b5..2032d8fd2c12 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToAudioService.cs @@ -49,6 +49,7 @@ public OpenAITextToAudioService( HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, apiKey, organization, endpoint, httpClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); } @@ -63,6 +64,7 @@ public OpenAITextToAudioService( OpenAIClient openAIClient, ILoggerFactory? loggerFactory = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextToAudioService))); } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs index 15ebcf049a93..e152c608922f 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAITextToImageService.cs @@ -50,6 +50,7 @@ public OpenAITextToImageService( HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, apiKey, organizationId, endpoint, httpClient, loggerFactory?.CreateLogger(this.GetType())); } @@ -64,6 +65,7 @@ public OpenAITextToImageService( OpenAIClient openAIClient, ILoggerFactory? loggerFactory = null) { + Verify.NotNullOrWhiteSpace(modelId, nameof(modelId)); this._client = new(modelId, openAIClient, loggerFactory?.CreateLogger(typeof(OpenAITextToImageService))); } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs new file mode 100644 index 000000000000..3b49c1850df0 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Connectors.OpenAI; + +/// +/// Execution settings associated with Open AI file upload . +/// +[Experimental("SKEXP0010")] +public sealed class OpenAIFileUploadExecutionSettings +{ + /// + /// Initializes a new instance of the class. + /// + /// The file name + /// The file purpose + public OpenAIFileUploadExecutionSettings(string fileName, OpenAIFilePurpose purpose) + { + Verify.NotNull(fileName, nameof(fileName)); + + this.FileName = fileName; + this.Purpose = purpose; + } + + /// + /// The file name. + /// + public string FileName { get; } + + /// + /// The file purpose. + /// + public OpenAIFilePurpose Purpose { get; } +}