From de56dd768b663067acb46c935c6286a39065e396 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 3 Jul 2024 12:48:23 +0100 Subject: [PATCH 1/4] FileService added --- .../Core/ClientCoreTests.cs | 9 - .../Services/OpenAIAudioToTextServiceTests.cs | 12 + .../Services/OpenAIFileServiceTests.cs | 298 ++++++++++++++++++ ...enAITextEmbeddingGenerationServiceTests.cs | 13 + .../Services/OpenAITextToAudioServiceTests.cs | 12 + .../Services/OpenAITextToImageServiceTests.cs | 12 + .../Core/ClientCore.File.cs | 108 +++++++ .../Connectors.OpenAIV2/Core/ClientCore.cs | 28 +- .../Models/OpenAIFilePurpose.cs | 22 ++ .../Models/OpenAIFileReference.cs | 38 +++ .../Services/OpenAIAudioToTextService.cs | 2 + .../Services/OpenAIFileService.cs | 130 ++++++++ .../OpenAITextEmbbedingGenerationService.cs | 2 + .../Services/OpenAITextToAudioService.cs | 2 + .../Services/OpenAITextToImageService.cs | 2 + .../OpenAIFileUploadExecutionSettings.cs | 35 ++ 16 files changed, 706 insertions(+), 19 deletions(-) create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFilePurpose.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2/Models/OpenAIFileReference.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs 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/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..96c3e869b843 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs @@ -0,0 +1,298 @@ +// 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"); + + // Assert + Assert.NotNull(service); + } + + [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"); + + // Assert + Assert.NotNull(service); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task DeleteFileWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + { + // Arrange + var service = this.CreateFileService(isAzure); + using var response = + isFailedRequest ? + this.CreateFailedResponse() : + this.CreateSuccessResponse( + """ + { + "id": "123", + "filename": "test.txt", + "purpose": "assistants", + "bytes": 120000, + "created_at": 1677610602 + } + """); + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + if (isFailedRequest) + { + await Assert.ThrowsAsync(() => service.DeleteFileAsync("file-id")); + } + else + { + await service.DeleteFileAsync("file-id"); + } + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task GetFileWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + { + // Arrange + var service = this.CreateFileService(isAzure); + using var response = + isFailedRequest ? + this.CreateFailedResponse() : + this.CreateSuccessResponse( + """ + { + "id": "123", + "filename": "file.txt", + "purpose": "assistants", + "bytes": 120000, + "created_at": 1677610602 + } + """); + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + if (isFailedRequest) + { + await Assert.ThrowsAsync(() => service.GetFileAsync("file-id")); + } + else + { + 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, true)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task GetFilesWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + { + // Arrange + var service = this.CreateFileService(isAzure); + using var response = + isFailedRequest ? + this.CreateFailedResponse() : + 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 + if (isFailedRequest) + { + await Assert.ThrowsAsync(() => service.GetFilesAsync()); + } + else + { + var files = (await service.GetFilesAsync()).ToArray(); + Assert.NotNull(files); + Assert.NotEmpty(files); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetFileContentWorksCorrectlyAsync(bool isAzure) + { + // Arrange + var data = BinaryData.FromString("Hello AI!"); + var service = this.CreateFileService(isAzure); + 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(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task UploadContentWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + { + // Arrange + var service = this.CreateFileService(isAzure); + using var response = + isFailedRequest ? + this.CreateFailedResponse() : + 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 + if (isFailedRequest) + { + await Assert.ThrowsAsync(() => service.UploadContentAsync(content, settings)); + } + else + { + 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); + } + } + + private OpenAIFileService CreateFileService(bool isAzure = false) + { + return + isAzure ? + 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/Core/ClientCore.File.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs new file mode 100644 index 000000000000..5786eca9a12f --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs @@ -0,0 +1,108 @@ +// 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 +{ + 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); + } + + internal async Task DeleteFileAsync( + string fileId, + CancellationToken cancellationToken) + { + await RunRequestAsync(() => this.Client.GetFileClient().DeleteFileAsync(fileId, cancellationToken)).ConfigureAwait(false); + } + + internal async Task GetFileAsync( + string fileId, + CancellationToken cancellationToken) + { + ClientResult response = await RunRequestAsync(() => this.Client.GetFileClient().GetFileAsync(fileId, cancellationToken)).ConfigureAwait(false); + return ConvertToFileReference(response.Value); + } + + internal async Task> GetFilesAsync(CancellationToken cancellationToken) + { + ClientResult response = await RunRequestAsync(() => this.Client.GetFileClient().GetFilesAsync(cancellationToken: cancellationToken)).ConfigureAwait(false); + return response.Value + .Select(ConvertToFileReference) + .ToList(); + } + + 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 (SKFilePurpose.FineTune == purpose) { return FileUploadPurpose.FineTune; } + if (SKFilePurpose.Assistants == purpose) { return FileUploadPurpose.Assistants; } + + /* WIB - Ignoring the following cases for now + if (SKFilePurpose.Vision == purpose) { return FileUploadPurpose.Vision; } + if (SKFilePurpose.Batch == purpose) { return FileUploadPurpose.Batch; } + */ + + throw new NotSupportedException($"Unsupported file purpose: {purpose}"); + } + + private static SKFilePurpose ConvertToFilePurpose(OAIFilePurpose purpose) + { + if (OAIFilePurpose.FineTune == purpose) { return SKFilePurpose.FineTune; } + if (OAIFilePurpose.Assistants == purpose) { return SKFilePurpose.Assistants; } + + /* WIB - Ignoring the following cases for now + if (OAIFilePurpose.FineTuneResults == purpose) { return SKFilePurpose.FineTuneResults; } + if (OAIFilePurpose.AssistantsOutput == purpose) { return SKFilePurpose.AssistantsOutput; } + if (OAIFilePurpose.Vision == purpose) { return SKFilePurpose.Vision; } + if (OAIFilePurpose.Batch == purpose) { return SKFilePurpose.Batch; } + if (OAIFilePurpose.BatchOutput == purpose) { return SKFilePurpose.BatchOutput; } + if (OAIFilePurpose.FineTuneResults == purpose) { return SKFilePurpose.FineTuneResults; } + */ + + throw new NotSupportedException($"Unsupported file purpose: {purpose}"); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs index 355000887f51..66afa344ebec 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; @@ -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; @@ -133,18 +138,21 @@ internal ClientCore( /// 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/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 a226d6c59040..d85b6738bfcd 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(OpenAITextToAudioService))); } @@ -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..415e021e1ded --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs @@ -0,0 +1,130 @@ +// 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 Azure OpenAI chat completion connector + /// + /// Azure Endpoint URL + /// Azure OpenAI API Key + /// OpenAI Organization Id (usually optional) + /// The API version to target. + /// 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, + string? version = 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 1a6038aa3f43..1b2d68bd8d9f 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(OpenAITextEmbeddingGenerationService))); } 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..42011da487f0 --- /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 serttings 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; } +} From 51ddab1e0fc3e6c7d3de8b1fa0004b9ba53051af Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 3 Jul 2024 15:57:33 +0100 Subject: [PATCH 2/4] Add missing UT --- .../KernelBuilderExtensionsTests.cs | 15 ++++++++ .../OpenAIFileUploadExecutionSettingsTests.cs | 24 +++++++++++++ .../ServiceCollectionExtensionsTests.cs | 16 +++++++++ .../OpenAIKernelBuilderExtensions.cs | 34 +++++++++++++++++++ .../OpenAIServiceCollectionExtensions.cs | 32 +++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/OpenAIFileUploadExecutionSettingsTests.cs diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs index bfa71f7e5ab3..014387b6a9a3 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,18 @@ 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(); + + // Assert + Assert.NotNull(service); + } } 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..8479b730db26 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,19 @@ 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(); + + // Assert + Assert.NotNull(service); + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs index ce4a4d9866e0..15feefb38cc4 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 } From 5a49ea5cbfe0805ba0fcc843e0d26ec9bd5cb5b1 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:13:34 +0100 Subject: [PATCH 3/4] Address PR Comments --- .../Services/OpenAIFileServiceTests.cs | 257 ++++++++++-------- .../Connectors.OpenAIV2.csproj | 4 +- .../Core/ClientCore.File.cs | 66 +++-- .../Connectors.OpenAIV2/Core/ClientCore.cs | 4 +- .../OpenAIKernelBuilderExtensions.cs | 2 +- .../Services/OpenAIFileService.cs | 8 +- .../OpenAIFileUploadExecutionSettings.cs | 2 +- 7 files changed, 189 insertions(+), 154 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs index 96c3e869b843..85ac2f2bf8d4 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Services/OpenAIFileServiceTests.cs @@ -39,9 +39,6 @@ public void ConstructorWorksCorrectlyForOpenAI(bool includeLoggerFactory) var service = includeLoggerFactory ? new OpenAIFileService("api-key", loggerFactory: this._mockLoggerFactory.Object) : new OpenAIFileService("api-key"); - - // Assert - Assert.NotNull(service); } [Theory] @@ -53,99 +50,98 @@ public void ConstructorWorksCorrectlyForAzure(bool includeLoggerFactory) 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 + } + """); - // Assert - Assert.NotNull(service); + this._messageHandlerStub.ResponseToReturn = response; + + // Act & Assert + await service.DeleteFileAsync("file-id"); } [Theory] - [InlineData(true, true)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(false, false)] - public async Task DeleteFileWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + [InlineData(true)] + [InlineData(false)] + public async Task DeleteFileFailsAsExpectedAsync(bool isCustomEndpoint) { // Arrange - var service = this.CreateFileService(isAzure); - using var response = - isFailedRequest ? - this.CreateFailedResponse() : - this.CreateSuccessResponse( - """ - { - "id": "123", - "filename": "test.txt", - "purpose": "assistants", - "bytes": 120000, - "created_at": 1677610602 - } - """); + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateFailedResponse(); + this._messageHandlerStub.ResponseToReturn = response; // Act & Assert - if (isFailedRequest) - { - await Assert.ThrowsAsync(() => service.DeleteFileAsync("file-id")); - } - else - { - await service.DeleteFileAsync("file-id"); - } + await Assert.ThrowsAsync(() => service.DeleteFileAsync("file-id")); } [Theory] - [InlineData(true, true)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(false, false)] - public async Task GetFileWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + [InlineData(true)] + [InlineData(false)] + public async Task GetFileWorksCorrectlyAsync(bool isCustomEndpoint) { // Arrange - var service = this.CreateFileService(isAzure); - using var response = - isFailedRequest ? - this.CreateFailedResponse() : - this.CreateSuccessResponse( - """ - { - "id": "123", - "filename": "file.txt", - "purpose": "assistants", - "bytes": 120000, - "created_at": 1677610602 - } - """); + 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 - if (isFailedRequest) - { - await Assert.ThrowsAsync(() => service.GetFileAsync("file-id")); - } - else - { - 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); - } + 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, true)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(false, false)] - public async Task GetFilesWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + [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(isAzure); - using var response = - isFailedRequest ? - this.CreateFailedResponse() : - this.CreateSuccessResponse( + var service = this.CreateFileService(isCustomEndpoint); + using var response = this.CreateSuccessResponse( """ { "data": [ @@ -166,29 +162,37 @@ public async Task GetFilesWorksCorrectlyAsync(bool isAzure, bool isFailedRequest ] } """); + this._messageHandlerStub.ResponseToReturn = response; // Act & Assert - if (isFailedRequest) - { - await Assert.ThrowsAsync(() => service.GetFilesAsync()); - } - else - { - var files = (await service.GetFilesAsync()).ToArray(); - Assert.NotNull(files); - Assert.NotEmpty(files); - } + 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 isAzure) + public async Task GetFileContentWorksCorrectlyAsync(bool isCustomEndpoint) { // Arrange var data = BinaryData.FromString("Hello AI!"); - var service = this.CreateFileService(isAzure); + var service = this.CreateFileService(isCustomEndpoint); this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { @@ -202,27 +206,23 @@ public async Task GetFileContentWorksCorrectlyAsync(bool isAzure) } [Theory] - [InlineData(true, true)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(false, false)] - public async Task UploadContentWorksCorrectlyAsync(bool isAzure, bool isFailedRequest) + [InlineData(false)] + [InlineData(true)] + public async Task UploadContentWorksCorrectlyAsync(bool isCustomEndpoint) { // Arrange - var service = this.CreateFileService(isAzure); - using var response = - isFailedRequest ? - this.CreateFailedResponse() : - this.CreateSuccessResponse( - """ - { - "id": "123", - "filename": "test.txt", - "purpose": "assistants", - "bytes": 120000, - "created_at": 1677610602 - } - """); + 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); @@ -239,25 +239,46 @@ public async Task UploadContentWorksCorrectlyAsync(bool isAzure, bool isFailedRe var content = new BinaryContent(stream.ToArray(), "text/plain"); // Act & Assert - if (isFailedRequest) - { - await Assert.ThrowsAsync(() => service.UploadContentAsync(content, settings)); - } - else + 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)) { - 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); + 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 isAzure = false) + private OpenAIFileService CreateFileService(bool isCustomEndpoint = false) { return - isAzure ? + isCustomEndpoint ? new OpenAIFileService(new Uri("http://localhost"), "api-key", httpClient: this._httpClient) : new OpenAIFileService("api-key", "organization", this._httpClient); } 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 index 5786eca9a12f..41a9f470c4b0 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.File.cs @@ -2,7 +2,6 @@ /* Phase 05 - - Ignoring the specific Purposes not implemented by current FileService. */ @@ -25,6 +24,14 @@ namespace Microsoft.SemanticKernel.Connectors.OpenAI; /// 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, @@ -35,6 +42,11 @@ internal async Task UploadFileAsync( 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) @@ -42,6 +54,12 @@ internal async Task DeleteFileAsync( 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) @@ -50,14 +68,26 @@ internal async Task GetFileAsync( return ConvertToFileReference(response.Value); } - internal async Task> GetFilesAsync(CancellationToken cancellationToken) + /// + /// 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) - .ToList(); + 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) @@ -78,31 +108,17 @@ private static OpenAIFileReference ConvertToFileReference(OpenAIFileInfo fileInf private static FileUploadPurpose ConvertToOpenAIFilePurpose(SKFilePurpose purpose) { - if (SKFilePurpose.FineTune == purpose) { return FileUploadPurpose.FineTune; } - if (SKFilePurpose.Assistants == purpose) { return FileUploadPurpose.Assistants; } - - /* WIB - Ignoring the following cases for now - if (SKFilePurpose.Vision == purpose) { return FileUploadPurpose.Vision; } - if (SKFilePurpose.Batch == purpose) { return FileUploadPurpose.Batch; } - */ + if (purpose == SKFilePurpose.Assistants) { return FileUploadPurpose.Assistants; } + if (purpose == SKFilePurpose.FineTune) { return FileUploadPurpose.FineTune; } - throw new NotSupportedException($"Unsupported file purpose: {purpose}"); + throw new KernelException($"Unknown {nameof(OpenAIFilePurpose)}: {purpose}."); } private static SKFilePurpose ConvertToFilePurpose(OAIFilePurpose purpose) { - if (OAIFilePurpose.FineTune == purpose) { return SKFilePurpose.FineTune; } - if (OAIFilePurpose.Assistants == purpose) { return SKFilePurpose.Assistants; } - - /* WIB - Ignoring the following cases for now - if (OAIFilePurpose.FineTuneResults == purpose) { return SKFilePurpose.FineTuneResults; } - if (OAIFilePurpose.AssistantsOutput == purpose) { return SKFilePurpose.AssistantsOutput; } - if (OAIFilePurpose.Vision == purpose) { return SKFilePurpose.Vision; } - if (OAIFilePurpose.Batch == purpose) { return SKFilePurpose.Batch; } - if (OAIFilePurpose.BatchOutput == purpose) { return SKFilePurpose.BatchOutput; } - if (OAIFilePurpose.FineTuneResults == purpose) { return SKFilePurpose.FineTuneResults; } - */ + if (purpose == OAIFilePurpose.Assistants) { return SKFilePurpose.Assistants; } + if (purpose == OAIFilePurpose.FineTune) { return SKFilePurpose.FineTune; } - throw new NotSupportedException($"Unsupported file purpose: {purpose}"); + 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 66afa344ebec..695f23579ad1 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Core/ClientCore.cs @@ -69,7 +69,7 @@ internal partial class ClientCore internal ILogger Logger { get; init; } /// - /// OpenAI / Azure OpenAI Client + /// OpenAI Client /// internal OpenAIClient Client { get; } @@ -134,7 +134,7 @@ 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( diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs index 15feefb38cc4..37ac7d384647 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Extensions/OpenAIKernelBuilderExtensions.cs @@ -293,7 +293,7 @@ OpenAIAudioToTextService Factory(IServiceProvider serviceProvider, object? _) => /// /// Add the OpenAI file service to the list /// - /// The instance to augment. + /// 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 diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs index 415e021e1ded..8b50df3f3639 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Services/OpenAIFileService.cs @@ -23,19 +23,17 @@ public sealed class OpenAIFileService private readonly ClientCore _client; /// - /// Create an instance of the Azure OpenAI chat completion connector + /// Create an instance of the OpenAI chat completion connector /// - /// Azure Endpoint URL - /// Azure OpenAI API Key + /// Non-default endpoint for the OpenAI API. + /// API Key /// OpenAI Organization Id (usually optional) - /// The API version to target. /// 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, - string? version = null, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs index 42011da487f0..3b49c1850df0 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2/Settings/OpenAIFileUploadExecutionSettings.cs @@ -5,7 +5,7 @@ namespace Microsoft.SemanticKernel.Connectors.OpenAI; /// -/// Execution serttings associated with Open AI file upload . +/// Execution settings associated with Open AI file upload . /// [Experimental("SKEXP0010")] public sealed class OpenAIFileUploadExecutionSettings From 3de853a8e334c2320a308df1d7c98929ff49335c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:17:01 +0100 Subject: [PATCH 4/4] Removing unneeded Assert --- .../Extensions/KernelBuilderExtensionsTests.cs | 3 --- .../Extensions/ServiceCollectionExtensionsTests.cs | 3 --- 2 files changed, 6 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs index 014387b6a9a3..6068dbe558da 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/KernelBuilderExtensionsTests.cs @@ -143,8 +143,5 @@ public void ItCanAddFileService() // Act var service = sut.AddOpenAIFiles("key").Build() .GetRequiredService(); - - // Assert - Assert.NotNull(service); } } diff --git a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 8479b730db26..19c030b820fb 100644 --- a/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAIV2.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -145,8 +145,5 @@ public void ItCanAddFileService() var service = sut.AddOpenAIFiles("key") .BuildServiceProvider() .GetRequiredService(); - - // Assert - Assert.NotNull(service); } }