diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs new file mode 100644 index 00000000000..91ffb136af5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building generators that can be chained in any order around an underlying . +/// The default implementation simply passes each call to the inner generator instance. +/// +[Experimental("MEAI001")] +public class DelegatingImageGenerator : IImageGenerator +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped generator instance. + /// is . + protected DelegatingImageGenerator(IImageGenerator innerGenerator) + { + InnerGenerator = Throw.IfNull(innerGenerator); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected IImageGenerator InnerGenerator { get; } + + /// + public virtual Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerGenerator.GenerateAsync(request, options, cancellationToken); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerGenerator.GetService(serviceType, serviceKey); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerGenerator.Dispose(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs new file mode 100644 index 00000000000..e630ecff8e9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a generator of images. +/// +[Experimental("MEAI001")] +public interface IImageGenerator : IDisposable +{ + /// + /// Sends an image generation request and returns the generated image as a . + /// + /// The image generation request containing the prompt and optional original images for editing. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// is . + /// The images generated by the . + Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs new file mode 100644 index 00000000000..f68aebd5b06 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for an image generation request. +[Experimental("MEAI001")] +public class ImageGenerationOptions +{ + /// + /// Gets or sets the number of images to generate. + /// + public int? Count { get; set; } + + /// + /// Gets or sets the size of the generated image. + /// + /// + /// If a provider only supports fixed sizes the closest supported size will be used. + /// + public Size? ImageSize { get; set; } + + /// + /// Gets or sets the media type (also known as MIME type) of the generated image. + /// + public string? MediaType { get; set; } + + /// + /// Gets or sets the model ID to use for image generation. + /// + public string? ModelId { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the image generation options from an underlying implementation. + /// + /// + /// The underlying implementation may have its own representation of options. + /// When is invoked with an , + /// that implementation may convert the provided options into its own representation in order to use it while performing + /// the operation. For situations where a consumer knows which concrete is being used + /// and how it represents options, a new instance of that implementation-specific options type may be returned by this + /// callback, for the implementation to use instead of creating a new instance. + /// Such implementations may mutate the supplied options instance further based on other settings supplied on this + /// instance or from other inputs, therefore, it is strongly recommended to not + /// return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + + /// + /// Gets or sets the response format of the generated image. + /// + public ImageGenerationResponseFormat? ResponseFormat { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + public virtual ImageGenerationOptions Clone() + { + ImageGenerationOptions options = new() + { + Count = Count, + MediaType = MediaType, + ImageSize = ImageSize, + ModelId = ModelId, + RawRepresentationFactory = RawRepresentationFactory, + ResponseFormat = ResponseFormat + }; + + return options; + } +} + +/// +/// Represents the requested response format of the generated image. +/// +/// +/// Not all implementations support all response formats and this value may be ignored by the implementation if not supported. +/// +[Experimental("MEAI001")] +public enum ImageGenerationResponseFormat +{ + /// + /// The generated image is returned as a URI pointing to the image resource. + /// + Uri, + + /// + /// The generated image is returned as in-memory image data. + /// + Data, + + /// + /// The generated image is returned as a hosted resource identifier, which can be used to retrieve the image later. + /// + Hosted, +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs new file mode 100644 index 00000000000..d519d08c731 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Represents a request for image generation. +[Experimental("MEAI001")] +public class ImageGenerationRequest +{ + /// Initializes a new instance of the class. + public ImageGenerationRequest() + { + } + + /// Initializes a new instance of the class. + /// The prompt to guide the image generation. + public ImageGenerationRequest(string prompt) + { + Prompt = prompt; + } + + /// Initializes a new instance of the class. + /// The prompt to guide the image generation. + /// The original images to base edits on. + public ImageGenerationRequest(string prompt, IEnumerable? originalImages) + { + Prompt = prompt; + OriginalImages = originalImages; + } + + /// Gets or sets the prompt to guide the image generation. + public string? Prompt { get; set; } + + /// + /// Gets or sets the original images to base edits on. + /// + /// + /// If this property is set, the request will behave as an image edit operation. + /// If this property is null or empty, the request will behave as a new image generation operation. + /// + public IEnumerable? OriginalImages { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs new file mode 100644 index 00000000000..22a6a7f0e12 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +#pragma warning disable EA0011 // Consider removing unnecessary conditional access operators + +namespace Microsoft.Extensions.AI; + +/// Represents the result of an image generation request. +[Experimental("MEAI001")] +public class ImageGenerationResponse +{ + /// The content items in the generated text response. + private IList? _contents; + + /// Initializes a new instance of the class. + [JsonConstructor] + public ImageGenerationResponse() + { + } + + /// Initializes a new instance of the class. + /// The contents for this response. + public ImageGenerationResponse(IList? contents) + { + _contents = contents; + } + + /// Gets or sets the raw representation of the image generation response from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// + /// Gets or sets the generated content items. Content will typically be DataContent for + /// images streamed from the generator or UriContent for remotely hosted images, but may also + /// be provider specific content types that represent the generated images. + /// + [AllowNull] + public IList Contents + { + get => _contents ??= []; + set => _contents = value; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs new file mode 100644 index 00000000000..93de115c0ec --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Extensions for . +[Experimental("MEAI001")] +public static class ImageGeneratorExtensions +{ + private static readonly Dictionary _extensionToMimeType = new(StringComparer.OrdinalIgnoreCase) + { + [".png"] = "image/png", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".webp"] = "image/webp", + [".gif"] = "image/gif", + [".bmp"] = "image/bmp", + [".tiff"] = "image/tiff", + [".tif"] = "image/tiff", + }; + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IImageGenerator generator, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + + return generator.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type + /// and throws an exception if one isn't available. + /// + /// The generator. + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static object GetRequiredService(this IImageGenerator generator, Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(serviceType); + + return + generator.GetService(serviceType, serviceKey) ?? + throw Throw.CreateMissingServiceException(serviceType, serviceKey); + } + + /// + /// Asks the for an object of type + /// and throws an exception if one isn't available. + /// + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService GetRequiredService(this IImageGenerator generator, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + + if (generator.GetService(typeof(TService), serviceKey) is not TService service) + { + throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); + } + + return service; + } + + /// + /// Generates images based on a text prompt. + /// + /// The image generator. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// or are . + /// The images generated by the generator. + public static Task GenerateImagesAsync( + this IImageGenerator generator, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt), options, cancellationToken); + } + + /// + /// Edits images based on original images and a text prompt. + /// + /// The image generator. + /// The images to base edits on. + /// The prompt to guide the image editing. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// , , or are . + /// The images generated by the generator. + public static Task EditImagesAsync( + this IImageGenerator generator, + IEnumerable originalImages, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(originalImages); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt, originalImages), options, cancellationToken); + } + + /// + /// Edits a single image based on the original image and the specified prompt. + /// + /// The image generator. + /// The single image to base edits on. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// , , or are . + /// The images generated by the generator. + public static Task EditImageAsync( + this IImageGenerator generator, + DataContent originalImage, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(originalImage); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt, [originalImage]), options, cancellationToken); + } + + /// + /// Edits a single image based on a byte array and the specified prompt. + /// + /// The image generator. + /// The byte array containing the image data to base edits on. + /// The filename for the image data. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// + /// , , or are . + /// + /// The images generated by the generator. + public static Task EditImageAsync( + this IImageGenerator generator, + ReadOnlyMemory originalImageData, + string fileName, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(fileName); + _ = Throw.IfNull(prompt); + + // Infer media type from file extension + string mediaType = GetMediaTypeFromFileName(fileName); + + var dataContent = new DataContent(originalImageData, mediaType) { Name = fileName }; + return generator.GenerateAsync(new ImageGenerationRequest(prompt, [dataContent]), options, cancellationToken); + } + + /// + /// Gets the media type based on the file extension. + /// + /// The filename to extract the media type from. + /// The inferred media type. + private static string GetMediaTypeFromFileName(string fileName) + { + string extension = Path.GetExtension(fileName); + + if (_extensionToMimeType.TryGetValue(extension, out string? mediaType)) + { + return mediaType; + } + + return "image/png"; // Default to PNG if unknown extension + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs new file mode 100644 index 00000000000..c5604155285 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Provides metadata about an . +[Experimental("MEAI001")] +public class ImageGeneratorMetadata +{ + /// Initializes a new instance of the class. + /// + /// The name of the image generation provider, if applicable. Where possible, this should map to the + /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// The URL for accessing the image generation provider, if applicable. + /// The ID of the image generation model used by default, if applicable. + public ImageGeneratorMetadata(string? providerName = null, Uri? providerUri = null, string? defaultModelId = null) + { + DefaultModelId = defaultModelId; + ProviderName = providerName; + ProviderUri = providerUri; + } + + /// Gets the name of the image generation provider. + /// + /// Where possible, this maps to the appropriate name defined in the + /// OpenTelemetry Semantic Conventions for Generative AI systems. + /// + public string? ProviderName { get; } + + /// Gets the URL for accessing the image generation provider. + public Uri? ProviderUri { get; } + + /// Gets the ID of the default model used by this image generator. + /// + /// This value can be if no default model is set on the corresponding . + /// An individual request may override this value via . + /// + public string? DefaultModelId { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 33531661813..4b8a4fb1576 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -70,6 +70,8 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(SpeechToTextResponse))] [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(IReadOnlyList))] + [JsonSerializable(typeof(ImageGenerationOptions))] + [JsonSerializable(typeof(ImageGenerationResponse))] [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(ChatMessage[]))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 3881f246d98..a5739fdb4ac 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -13,6 +13,7 @@ using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; +using OpenAI.Images; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long @@ -154,6 +155,14 @@ public static IChatClient AsIChatClient(this AssistantClient assistantClient, As public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioClient) => new OpenAISpeechToTextClient(audioClient); + /// Gets an for use with this . + /// The client. + /// An that can be used to generate images via the . + /// is . + [Experimental("MEAI001")] + public static IImageGenerator AsIImageGenerator(this ImageClient imageClient) => + new OpenAIImageGenerator(imageClient); + /// Gets an for use with this . /// The client. /// The number of dimensions to generate in each embedding. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs new file mode 100644 index 00000000000..fe1cb399e0d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs @@ -0,0 +1,234 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Images; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// Represents an for an OpenAI or . +internal sealed class OpenAIImageGenerator : IImageGenerator +{ + private static readonly Dictionary _mimeTypeToExtension = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/webp"] = ".webp", + ["image/gif"] = ".gif", + ["image/bmp"] = ".bmp", + ["image/tiff"] = ".tiff", + }; + + /// Metadata about the client. + private readonly ImageGeneratorMetadata _metadata; + + /// The underlying . + private readonly ImageClient _imageClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// is . + public OpenAIImageGenerator(ImageClient imageClient) + { + _ = Throw.IfNull(imageClient); + + _imageClient = imageClient; + + // https://github.com/openai/openai-dotnet/issues/215 + // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages + // implement the abstractions directly rather than providing adapters on top of the public APIs, + // the package can provide such implementations separate from what's exposed in the public API. + Uri providerUrl = typeof(ImageClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(imageClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; + + _metadata = new("openai", providerUrl, _imageClient.Model); + } + + /// + public async Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + string? prompt = request.Prompt; + _ = Throw.IfNull(prompt); + + // If the request has original images, treat this as an edit operation + if (request.OriginalImages is not null && request.OriginalImages.Any()) + { + ImageEditOptions editOptions = ToOpenAIImageEditOptions(options); + string? fileName = null; + Stream? imageStream = null; + + // Currently only a single image is supported for editing. + var originalImage = request.OriginalImages.FirstOrDefault(); + + if (originalImage is DataContent dataContent) + { + imageStream = MemoryMarshal.TryGetArray(dataContent.Data, out var array) ? + new MemoryStream(array.Array!, array.Offset, array.Count) : + new MemoryStream(dataContent.Data.ToArray()); + fileName = dataContent.Name; + + if (fileName is null) + { + // If no file name is provided, use the default based on the content type. + if (dataContent.MediaType is not null && _mimeTypeToExtension.TryGetValue(dataContent.MediaType, out var extension)) + { + fileName = $"image{extension}"; + } + else + { + fileName = "image.png"; // Default to PNG if no content type is available. + } + } + } + + GeneratedImageCollection editResult = await _imageClient.GenerateImageEditsAsync( + imageStream, fileName, prompt, options?.Count ?? 1, editOptions, cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(editResult); + } + + OpenAI.Images.ImageGenerationOptions openAIOptions = ToOpenAIImageGenerationOptions(options); + + GeneratedImageCollection result = await _imageClient.GenerateImagesAsync(prompt, options?.Count ?? 1, openAIOptions, cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(result); + } + + /// +#pragma warning disable S1067 // Expressions should not be too complex + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : + serviceKey is not null ? null : + serviceType == typeof(ImageGeneratorMetadata) ? _metadata : + serviceType == typeof(ImageClient) ? _imageClient : + serviceType.IsInstanceOfType(this) ? this : + null; +#pragma warning restore S1067 // Expressions should not be too complex + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IImageGenerator interface. + } + + /// + /// Converts a to an OpenAI . + /// + /// User's requested size. + /// Closest supported size. + private static GeneratedImageSize? ToOpenAIImageSize(Size? requestedSize) => + requestedSize is null ? null : new GeneratedImageSize(requestedSize.Value.Width, requestedSize.Value.Height); + + /// Converts a to a . + private static ImageGenerationResponse ToImageGenerationResponse(GeneratedImageCollection generatedImages) + { + string contentType = "image/png"; // Default content type for images + + // OpenAI doesn't expose the content type, so we need to read from the internal JSON representation. + // https://github.com/openai/openai-dotnet/issues/561 + IDictionary? additionalRawData = typeof(GeneratedImageCollection) + .GetProperty("SerializedAdditionalRawData", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(generatedImages) as IDictionary; + + if (additionalRawData?.TryGetValue("output_format", out var outputFormat) ?? false) + { + var stringJsonTypeInfo = (JsonTypeInfo)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string)); + var outputFormatString = JsonSerializer.Deserialize(outputFormat, stringJsonTypeInfo); + contentType = $"image/{outputFormatString}"; + } + + List contents = new(); + + foreach (GeneratedImage image in generatedImages) + { + if (image.ImageBytes is not null) + { + contents.Add(new DataContent(image.ImageBytes.ToMemory(), contentType)); + } + else if (image.ImageUri is not null) + { + contents.Add(new UriContent(image.ImageUri, contentType)); + } + else + { + throw new InvalidOperationException("Generated image does not contain a valid URI or byte array."); + } + } + + return new ImageGenerationResponse(contents) + { + RawRepresentation = generatedImages + }; + } + + /// Converts a to a . + private OpenAI.Images.ImageGenerationOptions ToOpenAIImageGenerationOptions(ImageGenerationOptions? options) + { + OpenAI.Images.ImageGenerationOptions result = options?.RawRepresentationFactory?.Invoke(this) as OpenAI.Images.ImageGenerationOptions ?? new(); + + if (result.OutputFileFormat is null) + { + if (options?.MediaType?.Equals("image/png", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Png; + } + else if (options?.MediaType?.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Jpeg; + } + else if (options?.MediaType?.Equals("image/webp", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Webp; + } + } + + result.ResponseFormat ??= options?.ResponseFormat switch + { + ImageGenerationResponseFormat.Uri => GeneratedImageFormat.Uri, + ImageGenerationResponseFormat.Data => GeneratedImageFormat.Bytes, + + // ImageGenerationResponseFormat.Hosted not supported by ImageGenerator, however other OpenAI API support file IDs. + _ => (GeneratedImageFormat?)null + }; + + result.Size ??= ToOpenAIImageSize(options?.ImageSize); + + return result; + } + + /// Converts a to a . + private ImageEditOptions ToOpenAIImageEditOptions(ImageGenerationOptions? options) + { + ImageEditOptions result = options?.RawRepresentationFactory?.Invoke(this) as ImageEditOptions ?? new(); + + result.ResponseFormat ??= options?.ResponseFormat switch + { + ImageGenerationResponseFormat.Uri => GeneratedImageFormat.Uri, + ImageGenerationResponseFormat.Data => GeneratedImageFormat.Bytes, + + // ImageGenerationResponseFormat.Hosted not supported by ImageGenerator, however other OpenAI API support file IDs. + _ => (GeneratedImageFormat?)null + }; + + result.Size ??= ToOpenAIImageSize(options?.ImageSize); + + return result; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs new file mode 100644 index 00000000000..b9e698a33f6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating image generator that configures a instance used by the remainder of the pipeline. +[Experimental("MEAI001")] +public sealed class ConfigureOptionsImageGenerator : DelegatingImageGenerator +{ + /// The callback delegate used to configure options. + private readonly Action _configureOptions; + + /// Initializes a new instance of the class with the specified callback. + /// The inner generator. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance + /// (or a newly constructed instance if the caller-supplied instance is ). + /// + /// or is . + /// + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via of the caller-supplied + /// instance if one was supplied. + /// + public ConfigureOptionsImageGenerator(IImageGenerator innerGenerator, Action configure) + : base(innerGenerator) + { + _configureOptions = Throw.IfNull(configure); + } + + /// + public override async Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.GenerateAsync(request, Configure(options), cancellationToken); + } + + /// Creates and configures the to pass along to the inner generator. + private ImageGenerationOptions Configure(ImageGenerationOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..337c80951ef --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class ConfigureOptionsImageGeneratorBuilderExtensions +{ + /// + /// Adds a callback that configures a to be passed to the next generator in the pipeline. + /// + /// The . + /// + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). + /// + /// or is . + /// + /// This method can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via ) + /// of the caller-supplied instance if one was supplied. + /// + /// The . + public static ImageGeneratorBuilder ConfigureOptions( + this ImageGeneratorBuilder builder, Action configure) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configure); + + return builder.Use(innerGenerator => new ConfigureOptionsImageGenerator(innerGenerator, configure)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs new file mode 100644 index 00000000000..9070ed8a59c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +[Experimental("MEAI001")] +public sealed class ImageGeneratorBuilder +{ + private readonly Func _innerGeneratorFactory; + + /// The registered generator factory instances. + private List>? _generatorFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + /// is . + public ImageGeneratorBuilder(IImageGenerator innerGenerator) + { + _ = Throw.IfNull(innerGenerator); + _innerGeneratorFactory = _ => innerGenerator; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + /// is . + public ImageGeneratorBuilder(Func innerGeneratorFactory) + { + _innerGeneratorFactory = Throw.IfNull(innerGeneratorFactory); + } + + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IImageGenerator Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var imageGenerator = _innerGeneratorFactory(services); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_generatorFactories is not null) + { + for (var i = _generatorFactories.Count - 1; i >= 0; i--) + { + imageGenerator = _generatorFactories[i](imageGenerator, services) ?? + throw new InvalidOperationException( + $"The {nameof(ImageGeneratorBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IImageGenerator)} instances."); + } + } + + return imageGenerator; + } + + /// Adds a factory for an intermediate image generator to the image generator pipeline. + /// The generator factory function. + /// The updated instance. + /// is . + public ImageGeneratorBuilder Use(Func generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + return Use((innerGenerator, _) => generatorFactory(innerGenerator)); + } + + /// Adds a factory for an intermediate image generator to the image generator pipeline. + /// The generator factory function. + /// The updated instance. + /// is . + public ImageGeneratorBuilder Use(Func generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + (_generatorFactories ??= []).Add(generatorFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs new file mode 100644 index 00000000000..e8242287b68 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with in the context of . +[Experimental("MEAI001")] +public static class ImageGeneratorBuilderImageGeneratorExtensions +{ + /// Creates a new using as its inner generator. + /// The generator to use as the inner generator. + /// The new instance. + /// is . + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner generator. + /// + public static ImageGeneratorBuilder AsBuilder(this IImageGenerator innerGenerator) + { + _ = Throw.IfNull(innerGenerator); + + return new ImageGeneratorBuilder(innerGenerator); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..cec943da309 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Provides extension methods for registering with a . +[Experimental("MEAI001")] +public static class ImageGeneratorBuilderServiceCollectionExtensions +{ + /// Registers a singleton in the . + /// The to which the generator should be added. + /// The inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// A that can be used to build a pipeline around the inner generator. + /// or is . + /// The generator is registered as a singleton service. + public static ImageGeneratorBuilder AddImageGenerator( + this IServiceCollection serviceCollection, + IImageGenerator innerGenerator, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddImageGenerator(serviceCollection, _ => innerGenerator, lifetime); + + /// Registers a singleton in the . + /// The to which the generator should be added. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// A that can be used to build a pipeline around the inner generator. + /// or is . + /// The generator is registered as a singleton service. + public static ImageGeneratorBuilder AddImageGenerator( + this IServiceCollection serviceCollection, + Func innerGeneratorFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerGeneratorFactory); + + var builder = new ImageGeneratorBuilder(innerGeneratorFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IImageGenerator), builder.Build, lifetime)); + return builder; + } + + /// Registers a keyed singleton in the . + /// The to which the generator should be added. + /// The key with which to associate the generator. + /// The inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// A that can be used to build a pipeline around the inner generator. + /// , , or is . + /// The generator is registered as a scoped service. + public static ImageGeneratorBuilder AddKeyedImageGenerator( + this IServiceCollection serviceCollection, + object serviceKey, + IImageGenerator innerGenerator, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddKeyedImageGenerator(serviceCollection, serviceKey, _ => innerGenerator, lifetime); + + /// Registers a keyed singleton in the . + /// The to which the generator should be added. + /// The key with which to associate the generator. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// A that can be used to build a pipeline around the inner generator. + /// , , or is . + /// The generator is registered as a scoped service. + public static ImageGeneratorBuilder AddKeyedImageGenerator( + this IServiceCollection serviceCollection, + object serviceKey, + Func innerGeneratorFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(serviceKey); + _ = Throw.IfNull(innerGeneratorFactory); + + var builder = new ImageGeneratorBuilder(innerGeneratorFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IImageGenerator), serviceKey, factory: (services, serviceKey) => builder.Build(services), lifetime)); + return builder; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs new file mode 100644 index 00000000000..2eee33456c1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.AI.OpenTelemetryConsts.GenAI; + +namespace Microsoft.Extensions.AI; + +/// A delegating image generator that logs image generation operations to an . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// When the employed enables , the contents of +/// prompts and options are logged. These prompts and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Prompts and options are not logged at other logging levels. +/// +/// +[Experimental("MEAI001")] +public partial class LoggingImageGenerator : DelegatingImageGenerator +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + /// or is . + public LoggingImageGenerator(IImageGenerator innerGenerator, ILogger logger) + : base(innerGenerator) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + /// The value being set is . + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(GenerateAsync), request.Prompt ?? string.Empty, AsJson(options), AsJson(this.GetService())); + } + else + { + LogInvoked(nameof(GenerateAsync)); + } + } + + try + { + var response = await base.GenerateAsync(request, options, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace) && response.Contents.All(c => c is not DataContent)) + { + LogCompletedSensitive(nameof(GenerateAsync), AsJson(response)); + } + else + { + LogCompleted(nameof(GenerateAsync)); + } + } + + return response; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GenerateAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GenerateAsync), ex); + throw; + } + } + + private string AsJson(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Prompt: {Prompt}. Options: {ImageGenerationOptions}. Metadata: {ImageGeneratorMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string prompt, string imageGenerationOptions, string imageGeneratorMetadata); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} completed: {ImageGenerationResponse}.")] + private partial void LogCompletedSensitive(string methodName, string imageGenerationResponse); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..ece65d942ed --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class LoggingImageGeneratorBuilderExtensions +{ + /// Adds logging to the image generator pipeline. + /// The . + /// + /// An optional used to create a logger with which logging should be performed. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + /// + /// + /// When the employed enables , the contents of + /// prompts and options are logged. These prompts and options may contain sensitive application data. + /// is disabled by default and should never be enabled in a production environment. + /// Prompts and options are not logged at other logging levels. + /// + /// + public static ImageGeneratorBuilder UseLogging( + this ImageGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerGenerator, services) => + { + loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingImageGenerator will end up + // being an expensive nop, so skip adding it and just return the inner generator. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerGenerator; + } + + var imageGenerator = new LoggingImageGenerator(innerGenerator, loggerFactory.CreateLogger(typeof(LoggingImageGenerator))); + configure?.Invoke(imageGenerator); + return imageGenerator; + }); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs new file mode 100644 index 00000000000..7e8d189b851 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingImageGeneratorTests +{ + [Fact] + public void RequiresInnerImageGenerator() + { + Assert.Throws("innerGenerator", () => new NoOpDelegatingImageGenerator(null!)); + } + + [Fact] + public async Task GenerateImagesAsyncDefaultsToInnerGeneratorAsync() + { + // Arrange + var expectedRequest = new ImageGenerationRequest("test prompt"); + var expectedOptions = new ImageGenerationOptions(); + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedResponse = new ImageGenerationResponse(); + using var inner = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var resultTask = delegating.GenerateAsync(expectedRequest, expectedOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedResponse); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedResponse, await resultTask); + } + + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestImageGenerator(); + using var delegating = new NoOpDelegatingImageGenerator(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestImageGenerator(); + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var generator = delegating.GetService(); + + // Assert + Assert.Same(delegating, generator); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedKey = new object(); + using var expectedResult = new TestImageGenerator(); + using var inner = new TestImageGenerator + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var generator = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, generator); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestImageGenerator + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var tzi = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + [Fact] + public void Dispose_SetsFlag() + { + using var inner = new TestImageGenerator(); + var delegating = new NoOpDelegatingImageGenerator(inner); + Assert.False(inner.DisposeInvoked); + + delegating.Dispose(); + Assert.True(inner.DisposeInvoked); + } + + [Fact] + public void Dispose_MultipleCallsSafe() + { + using var inner = new TestImageGenerator(); + var delegating = new NoOpDelegatingImageGenerator(inner); + + delegating.Dispose(); + Assert.True(inner.DisposeInvoked); + + // Second dispose should not throw +#pragma warning disable S3966 + delegating.Dispose(); +#pragma warning restore S3966 + Assert.True(inner.DisposeInvoked); + } + + private sealed class NoOpDelegatingImageGenerator(IImageGenerator innerGenerator) + : DelegatingImageGenerator(innerGenerator); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs new file mode 100644 index 00000000000..f6cd167e82a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Drawing; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGenerationOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ImageGenerationOptions options = new(); + Assert.Null(options.ResponseFormat); + Assert.Null(options.Count); + Assert.Null(options.ImageSize); + Assert.Null(options.MediaType); + Assert.Null(options.ModelId); + Assert.Null(options.RawRepresentationFactory); + + ImageGenerationOptions clone = options.Clone(); + Assert.Null(clone.ResponseFormat); + Assert.Null(clone.Count); + Assert.Null(clone.ImageSize); + Assert.Null(clone.MediaType); + Assert.Null(clone.ModelId); + Assert.Null(clone.RawRepresentationFactory); + } + + [Fact] + public void Properties_Roundtrip() + { + ImageGenerationOptions options = new(); + + Func factory = generator => new { Representation = "raw data" }; + + options.ResponseFormat = ImageGenerationResponseFormat.Data; + options.Count = 5; + options.ImageSize = new Size(1024, 768); + options.MediaType = "image/png"; + options.ModelId = "modelId"; + options.RawRepresentationFactory = factory; + + Assert.Equal(ImageGenerationResponseFormat.Data, options.ResponseFormat); + Assert.Equal(5, options.Count); + Assert.Equal(new Size(1024, 768), options.ImageSize); + Assert.Equal("image/png", options.MediaType); + Assert.Equal("modelId", options.ModelId); + Assert.Same(factory, options.RawRepresentationFactory); + + ImageGenerationOptions clone = options.Clone(); + Assert.Equal(ImageGenerationResponseFormat.Data, clone.ResponseFormat); + Assert.Equal(5, clone.Count); + Assert.Equal(new Size(1024, 768), clone.ImageSize); + Assert.Equal("image/png", clone.MediaType); + Assert.Equal("modelId", clone.ModelId); + Assert.Same(factory, clone.RawRepresentationFactory); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ImageGenerationOptions options = new() + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 3, + ImageSize = new Size(256, 256), + MediaType = "image/jpeg", + ModelId = "test-model", + }; + + string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ImageGenerationOptions); + + ImageGenerationOptions? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationOptions); + Assert.NotNull(deserialized); + + Assert.Equal(ImageGenerationResponseFormat.Data, deserialized.ResponseFormat); + Assert.Equal(3, deserialized.Count); + Assert.Equal(new Size(256, 256), deserialized.ImageSize); + Assert.Equal("image/jpeg", deserialized.MediaType); + Assert.Equal("test-model", deserialized.ModelId); + } + + [Fact] + public void Clone_CreatesIndependentCopy() + { + ImageGenerationOptions original = new() + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 2, + ImageSize = new Size(512, 512), + MediaType = "image/png", + ModelId = "original-model" + }; + + ImageGenerationOptions clone = original.Clone(); + + // Modify original + original.ResponseFormat = ImageGenerationResponseFormat.Uri; + original.Count = 1; + original.ImageSize = new Size(1024, 1024); + original.MediaType = "image/jpeg"; + original.ModelId = "modified-model"; + + // Clone should remain unchanged + Assert.Equal(ImageGenerationResponseFormat.Data, clone.ResponseFormat); + Assert.Equal(2, clone.Count); + Assert.Equal(new Size(512, 512), clone.ImageSize); + Assert.Equal("image/png", clone.MediaType); + Assert.Equal("original-model", clone.ModelId); + } + + [Theory] + [InlineData(ImageGenerationResponseFormat.Uri)] + [InlineData(ImageGenerationResponseFormat.Data)] + [InlineData(ImageGenerationResponseFormat.Hosted)] + public void ImageGenerationResponseFormat_Values_AreValid(ImageGenerationResponseFormat responseFormat) + { + Assert.True(Enum.IsDefined(typeof(ImageGenerationResponseFormat), responseFormat)); + } + + [Fact] + public void ImageGenerationResponseFormat_JsonSerialization_Roundtrips() + { + foreach (ImageGenerationResponseFormat responseFormat in Enum.GetValues(typeof(ImageGenerationResponseFormat))) + { + string json = JsonSerializer.Serialize(responseFormat, TestJsonSerializerContext.Default.ImageGenerationResponseFormat); + ImageGenerationResponseFormat deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponseFormat); + Assert.Equal(responseFormat, deserialized); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs new file mode 100644 index 00000000000..7b244dfeb53 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGenerationResponseTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ImageGenerationResponse response = new(); + Assert.Empty(response.Contents); + Assert.NotNull(response.Contents); + Assert.Same(response.Contents, response.Contents); + Assert.Empty(response.Contents); + Assert.Null(response.RawRepresentation); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Constructor_List_PropsRoundtrip(int contentCount) + { + List content = []; + for (int i = 0; i < contentCount; i++) + { + content.Add(new UriContent(new Uri($"https://example.com/image-{i}.png"), "image/png")); + } + + ImageGenerationResponse response = new(content); + + Assert.Same(response.Contents, response.Contents); + if (contentCount == 0) + { + Assert.Empty(response.Contents); + } + else + { + Assert.Equal(contentCount, response.Contents.Count); + for (int i = 0; i < contentCount; i++) + { + UriContent uc = Assert.IsType(response.Contents[i]); + Assert.Equal($"https://example.com/image-{i}.png", uc.Uri.ToString()); + Assert.Equal("image/png", uc.MediaType); + } + } + } + + [Fact] + public void Contents_SetNull_ReturnsEmpty() + { + ImageGenerationResponse response = new() + { + Contents = null! + }; + Assert.NotNull(response.Contents); + Assert.Empty(response.Contents); + } + + [Fact] + public void Contents_Set_Roundtrips() + { + ImageGenerationResponse response = new(); + byte[] imageData = [1, 2, 3, 4]; + + List contents = [ + new UriContent(new Uri("https://example.com/image1.png"), "image/png"), + new DataContent(imageData, "image/jpeg") + ]; + + response.Contents = contents; + Assert.Same(contents, response.Contents); + } + + [Fact] + public void RawRepresentation_Roundtrips() + { + ImageGenerationResponse response = new(); + Assert.Null(response.RawRepresentation); + + object representation = new { test = "value" }; + response.RawRepresentation = representation; + Assert.Same(representation, response.RawRepresentation); + + response.RawRepresentation = null; + Assert.Null(response.RawRepresentation); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + List contents = [ + new UriContent(new Uri("https://example.com/image1.png"), "image/png"), + new DataContent((byte[])[1, 2, 3, 4], "image/jpeg") + ]; + + ImageGenerationResponse response = new(contents); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + + Assert.Equal(2, deserialized.Contents.Count); + + UriContent uriContent = Assert.IsType(deserialized.Contents[0]); + Assert.Equal("https://example.com/image1.png", uriContent.Uri.ToString()); + Assert.Equal("image/png", uriContent.MediaType); + + DataContent dataContent = Assert.IsType(deserialized.Contents[1]); + Assert.Equal([1, 2, 3, 4], dataContent.Data.ToArray()); + Assert.Equal("image/jpeg", dataContent.MediaType); + } + + [Fact] + public void JsonSerialization_Empty_Roundtrips() + { + ImageGenerationResponse response = new(); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + Assert.Empty(deserialized.Contents); + } + + [Fact] + public void JsonSerialization_WithVariousContentTypes_Roundtrips() + { + List contents = [ + new UriContent(new Uri("https://example.com/image.png"), "image/png"), + new DataContent((byte[])[255, 216, 255, 224], "image/jpeg"), + new TextContent("Generated image description") // Edge case: text content in image response + ]; + + ImageGenerationResponse response = new(contents); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Contents.Count); + + Assert.IsType(deserialized.Contents[0]); + Assert.IsType(deserialized.Contents[1]); + Assert.IsType(deserialized.Contents[2]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs new file mode 100644 index 00000000000..a68726685eb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorExtensionsTests +{ + [Fact] + public void GetService_InvalidArgs_Throws() + { + Assert.Throws("generator", () => + { + _ = ImageGeneratorExtensions.GetService(null!); + }); + } + + [Fact] + public void GetService_ValidGenerator_CallsUnderlyingGetService() + { + using var testGenerator = new TestImageGenerator(); + var expectedResult = new object(); + var expectedServiceKey = new object(); + + testGenerator.GetServiceCallback = (serviceType, serviceKey) => + { + Assert.Equal(typeof(object), serviceType); + Assert.Same(expectedServiceKey, serviceKey); + return expectedResult; + }; + + var result = testGenerator.GetService(expectedServiceKey); + Assert.Same(expectedResult, result); + } + + [Fact] + public void GetService_ReturnsCorrectType() + { + using var testGenerator = new TestImageGenerator(); + var metadata = new ImageGeneratorMetadata("test", null, "model"); + + testGenerator.GetServiceCallback = (serviceType, serviceKey) => + { + return (serviceType == typeof(ImageGeneratorMetadata)) ? metadata : null; + }; + + var result = testGenerator.GetService(); + Assert.Same(metadata, result); + + var nullResult = testGenerator.GetService(); + Assert.Null(nullResult); + } + + [Fact] + public async Task EditImageAsync_DataContent_CallsGenerateImagesAsync() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var dataContent = new DataContent(imageData, "image/png") { Name = "test.png" }; + var prompt = "Edit this image"; + var options = new ImageGenerationOptions { Count = 2 }; + var expectedResponse = new ImageGenerationResponse(); + var cancellationToken = new CancellationToken(canceled: false); + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + Assert.Single(request.OriginalImages); + Assert.Same(dataContent, Assert.Single(request.OriginalImages)); + Assert.Equal(prompt, request.Prompt); + Assert.Same(options, o); + Assert.Equal(cancellationToken, ct); + return Task.FromResult(expectedResponse); + }; + + // Act + var result = await testGenerator.EditImageAsync(dataContent, prompt, options, cancellationToken); + + // Assert + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task EditImageAsync_DataContent_NullArguments_Throws() + { + using var testGenerator = new TestImageGenerator(); + var dataContent = new DataContent(new byte[] { 1, 2, 3 }, "image/png"); + + await Assert.ThrowsAsync("generator", async () => + await ImageGeneratorExtensions.EditImageAsync(null!, dataContent, "prompt")); + + await Assert.ThrowsAsync("originalImage", async () => + await testGenerator.EditImageAsync(null!, "prompt")); + + await Assert.ThrowsAsync("prompt", async () => + await testGenerator.EditImageAsync(dataContent, null!)); + } + + [Fact] + public async Task EditImageAsync_ByteArray_CallsGenerateImagesAsync() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var fileName = "test.jpg"; + var prompt = "Edit this image"; + var options = new ImageGenerationOptions { Count = 2 }; + var expectedResponse = new ImageGenerationResponse(); + var cancellationToken = new CancellationToken(canceled: false); + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + Assert.Single(request.OriginalImages); + var dataContent = Assert.IsType(Assert.Single(request.OriginalImages)); + Assert.Equal(imageData, dataContent.Data.ToArray()); + Assert.Equal("image/jpeg", dataContent.MediaType); + Assert.Equal(fileName, dataContent.Name); + Assert.Equal(prompt, request.Prompt); + Assert.Same(options, o); + Assert.Equal(cancellationToken, ct); + return Task.FromResult(expectedResponse); + }; + + // Act + var result = await testGenerator.EditImageAsync(imageData, fileName, prompt, options, cancellationToken); + + // Assert + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullGenerator_Throws() + { + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("generator", async () => + await ImageGeneratorExtensions.EditImageAsync(null!, imageData, "test.png", "prompt")); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullFileName_Throws() + { + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("fileName", async () => + await testGenerator.EditImageAsync(imageData, null!, "prompt")); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullPrompt_Throws() + { + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("prompt", async () => + await testGenerator.EditImageAsync(imageData, "test.png", null!)); + } + + [Theory] + [InlineData("test.png", "image/png")] + [InlineData("test.jpg", "image/jpeg")] + [InlineData("test.jpeg", "image/jpeg")] + [InlineData("test.webp", "image/webp")] + [InlineData("test.gif", "image/gif")] + [InlineData("test.bmp", "image/bmp")] + [InlineData("test.tiff", "image/tiff")] + [InlineData("test.tif", "image/tiff")] + [InlineData("test.unknown", "image/png")] // Unknown extension defaults to PNG + [InlineData("TEST.PNG", "image/png")] // Case insensitive + public async Task EditImageAsync_ByteArray_InfersCorrectMediaType(string fileName, string expectedMediaType) + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var prompt = "Edit this image"; + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + var dataContent = Assert.IsType(Assert.Single(request.OriginalImages)); + Assert.Equal(expectedMediaType, dataContent.MediaType); + return Task.FromResult(new ImageGenerationResponse()); + }; + + // Act & Assert + await testGenerator.EditImageAsync(imageData, fileName, prompt); + } + + [Fact] + public async Task EditImageAsync_AllMethods_PassDefaultOptionsAndCancellation() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var dataContent = new DataContent(imageData, "image/png"); + var prompt = "Edit this image"; + + int callCount = 0; + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + callCount++; + Assert.Null(o); // Default options should be null + Assert.Equal(CancellationToken.None, ct); // Default cancellation token + Assert.NotNull(request.OriginalImages); // Should have original images for editing + return Task.FromResult(new ImageGenerationResponse()); + }; + + // Act - Test all two overloads with default parameters + await testGenerator.EditImageAsync(dataContent, prompt); + await testGenerator.EditImageAsync(imageData, "test.png", prompt); + + // Assert + Assert.Equal(2, callCount); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs new file mode 100644 index 00000000000..193a02bde3e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorMetadataTests +{ + [Fact] + public void Constructor_NullValues_AllowedAndRoundtrip() + { + ImageGeneratorMetadata metadata = new(null, null, null); + Assert.Null(metadata.ProviderName); + Assert.Null(metadata.ProviderUri); + Assert.Null(metadata.DefaultModelId); + } + + [Fact] + public void Constructor_Value_Roundtrips() + { + var uri = new Uri("https://example.com"); + ImageGeneratorMetadata metadata = new("providerName", uri, "theModel"); + Assert.Equal("providerName", metadata.ProviderName); + Assert.Same(uri, metadata.ProviderUri); + Assert.Equal("theModel", metadata.DefaultModelId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs new file mode 100644 index 00000000000..2f60d3bb052 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs @@ -0,0 +1,155 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorTests +{ + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + using var generator = new TestImageGenerator(); + generator.GetServiceCallback = (serviceType, serviceKey) => + { + // When serviceKey is not null, should return null per interface contract + return serviceKey is not null ? null : new object(); + }; + + var result = generator.GetService(typeof(object), "someKey"); + Assert.Null(result); + } + + [Fact] + public void GetService_WithoutServiceKey_CallsCallback() + { + using var generator = new TestImageGenerator(); + var expectedResult = new object(); + + generator.GetServiceCallback = (serviceType, serviceKey) => + { + Assert.Equal(typeof(object), serviceType); + Assert.Null(serviceKey); + return expectedResult; + }; + + var result = generator.GetService(typeof(object)); + Assert.Same(expectedResult, result); + } + + [Fact] + public async Task GenerateImagesAsync_CallsCallback() + { + var expectedResponse = new ImageGenerationResponse(); + var expectedOptions = new ImageGenerationOptions(); + using var cts = new CancellationTokenSource(); + var expectedRequest = new ImageGenerationRequest("test prompt"); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(expectedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + } + }; + + var result = await generator.GenerateAsync(expectedRequest, expectedOptions, cts.Token); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task GenerateImagesAsync_NoCallback_ReturnsEmptyResponse() + { + using var generator = new TestImageGenerator(); + var result = await generator.GenerateAsync(new ImageGenerationRequest("test prompt"), null); + Assert.NotNull(result); + Assert.Empty(result.Contents); + } + + [Fact] + public void Dispose_SetsFlag() + { + var generator = new TestImageGenerator(); + Assert.False(generator.DisposeInvoked); + + generator.Dispose(); + Assert.True(generator.DisposeInvoked); + } + + [Fact] + public void Dispose_MultipleCallsSafe() + { + var generator = new TestImageGenerator(); + + generator.Dispose(); + Assert.True(generator.DisposeInvoked); + + // Second dispose should not throw +#pragma warning disable S3966 + generator.Dispose(); +#pragma warning restore S3966 + Assert.True(generator.DisposeInvoked); + } + + [Fact] + public async Task GenerateImagesAsync_WithOptions_PassesThroughCorrectly() + { + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 3, + ImageSize = new Size(1024, 768), + MediaType = "image/png", + ModelId = "test-model", + }; + + var expectedRequest = new ImageGenerationRequest("test prompt"); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, receivedOptions, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(options, receivedOptions); + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + await generator.GenerateAsync(expectedRequest, options); + } + + [Fact] + public async Task GenerateImagesAsync_WithEditRequest_PassesThroughCorrectly() + { + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 2, + MediaType = "image/jpeg", + ModelId = "edit-model", + }; + + AIContent[] originalImages = [new DataContent((byte[])[1, 2, 3, 4], "image/png")]; + var expectedRequest = new ImageGenerationRequest("edit prompt", originalImages); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, receivedOptions, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(options, receivedOptions); + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + await generator.GenerateAsync(expectedRequest, options); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs new file mode 100644 index 00000000000..4db1cca7377 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +public sealed class TestImageGenerator : IImageGenerator +{ + public TestImageGenerator() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + public IServiceProvider? Services { get; set; } + + public Func>? GenerateImagesAsyncCallback { get; set; } + + public Func GetServiceCallback { get; set; } + + public bool DisposeInvoked { get; private set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return GenerateImagesAsyncCallback?.Invoke(request, options, cancellationToken) ?? + Task.FromResult(new ImageGenerationResponse()); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return GetServiceCallback.Invoke(serviceType, serviceKey); + } + + public void Dispose() + { + DisposeInvoked = true; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index d15f0a19fa9..609dac264eb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -20,6 +20,8 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(SpeechToTextResponseUpdateKind))] [JsonSerializable(typeof(SpeechToTextOptions))] +[JsonSerializable(typeof(ImageGenerationResponse))] +[JsonSerializable(typeof(ImageGenerationOptions))] [JsonSerializable(typeof(ChatOptions))] [JsonSerializable(typeof(EmbeddingGenerationOptions))] [JsonSerializable(typeof(Dictionary))] diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..8e3078efbb5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +public abstract class ImageGeneratorIntegrationTests : IDisposable +{ + private readonly IImageGenerator? _generator; + + protected ImageGeneratorIntegrationTests() + { + _generator = CreateGenerator(); + } + + public void Dispose() + { + _generator?.Dispose(); + GC.SuppressFinalize(this); + } + + protected abstract IImageGenerator? CreateGenerator(); + + [ConditionalFact] + public virtual async Task GenerateImagesAsync_SingleImageGeneration() + { + SkipIfNotEnabled(); + + var options = new ImageGenerationOptions + { + Count = 1 + }; + + var response = await _generator.GenerateImagesAsync("A simple drawing of a house", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + + var content = response.Contents[0]; + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + + [ConditionalFact] + public virtual async Task GenerateImagesAsync_MultipleImages() + { + SkipIfNotEnabled(); + + var options = new ImageGenerationOptions + { + Count = 2 + }; + + var response = await _generator.GenerateImagesAsync("A cat sitting on a table", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Equal(2, response.Contents.Count); + + foreach (var content in response.Contents) + { + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + } + + [ConditionalFact] + public virtual async Task EditImagesAsync_SingleImage() + { + SkipIfNotEnabled(); + + var imageData = GetImageData("dotnet.png"); + AIContent[] originalImages = [new DataContent(imageData, "image/png") { Name = "dotnet.png" }]; + + var options = new ImageGenerationOptions + { + Count = 1 + }; + + var response = await _generator.EditImagesAsync(originalImages, "Add a red border and make the background tie-dye", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + + var content = response.Contents[0]; + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + + private static byte[] GetImageData(string fileName) + { + using Stream? s = typeof(ImageGeneratorIntegrationTests).Assembly.GetManifestResourceStream($"Microsoft.Extensions.AI.Resources.{fileName}"); + Assert.NotNull(s); + using MemoryStream ms = new(); + s.CopyTo(ms); + return ms.ToArray(); + } + + [MemberNotNull(nameof(_generator))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || _generator is null) + { + throw new SkipTestException("Generator is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj index ddc72caa90d..0b4865f577e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj @@ -32,6 +32,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md index 3b99e9bccc1..988ab2d08f5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md @@ -17,6 +17,7 @@ Optionally also run the following. The values shown here are the defaults if you ``` dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +dotnet user-secrets set OpenAI:ImageModel dall-e-3 ``` ### Configuring OpenAI tests (Azure OpenAI) @@ -35,6 +36,7 @@ Optionally also run the following. The values shown here are the defaults if you ``` dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +dotnet user-secrets set OpenAI:ImageModel dall-e-3 ``` Your account must have models matching these names. diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..ce0cdb7cf82 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public class OpenAIImageGeneratorIntegrationTests : ImageGeneratorIntegrationTests +{ + protected override IImageGenerator? CreateGenerator() + => IntegrationTestHelpers.GetOpenAIClient()? + .GetImageClient(TestRunnerConfiguration.Instance["OpenAI:ImageModel"] ?? "dall-e-3") + .AsIImageGenerator(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs new file mode 100644 index 00000000000..607b1e2859e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using OpenAI; +using OpenAI.Images; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIImageGeneratorTests +{ + [Fact] + public void AsIImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("imageClient", () => ((ImageClient)null!).AsIImageGenerator()); + } + + [Fact] + public void AsIImageGenerator_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "dall-e-3"; + + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IImageGenerator imageClient = client.GetImageClient(model).AsIImageGenerator(); + var metadata = imageClient.GetService(); + Assert.Equal(endpoint, metadata?.ProviderUri); + Assert.Equal(model, metadata?.DefaultModelId); + } + + [Fact] + public void GetService_ReturnsExpectedServices() + { + var client = new OpenAIClient(new ApiKeyCredential("key")); + IImageGenerator imageClient = client.GetImageClient("dall-e-3").AsIImageGenerator(); + + Assert.Same(imageClient, imageClient.GetService()); + Assert.Same(imageClient, imageClient.GetService()); + Assert.NotNull(imageClient.GetService()); + Assert.NotNull(imageClient.GetService()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs new file mode 100644 index 00000000000..ba37cad1b54 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsImageGeneratorTests +{ + [Fact] + public void ConfigureOptionsImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new ConfigureOptionsImageGenerator(null!, _ => { })); + Assert.Throws("configure", () => new ConfigureOptionsImageGenerator(new TestImageGenerator(), null!)); + } + + [Fact] + public void ConfigureOptions_InvalidArgs_Throws() + { + using var innerGenerator = new TestImageGenerator(); + var builder = innerGenerator.AsBuilder(); + Assert.Throws("configure", () => builder.ConfigureOptions(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextGenerator(bool nullProvidedOptions) + { + ImageGenerationOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" }; + ImageGenerationOptions? returnedOptions = null; + ImageGenerationResponse expectedResponse = new([]); + using CancellationTokenSource cts = new(); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (prompt, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + }, + + }; + + using var generator = innerGenerator + .AsBuilder() + .ConfigureOptions(options => + { + Assert.NotSame(providedOptions, options); + if (nullProvidedOptions) + { + Assert.Null(options.ModelId); + } + else + { + Assert.Equal(providedOptions!.ModelId, options.ModelId); + } + + returnedOptions = options; + }) + .Build(); + + var response1 = await generator.GenerateImagesAsync("test prompt", providedOptions, cts.Token); + Assert.Same(expectedResponse, response1); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs new file mode 100644 index 00000000000..b65495e506b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorDependencyInjectionPatterns +{ + private IServiceCollection ServiceCollection { get; } = new ServiceCollection(); + + [Fact] + public void CanRegisterSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddImageGenerator(services => new TestImageGenerator { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestImageGenerator(); + ServiceCollection.AddImageGenerator(singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddKeyedImageGenerator("mykey", services => new TestImageGenerator { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestImageGenerator(); + ServiceCollection.AddKeyedImageGenerator("mykey", singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddImageGenerator_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + ImageGeneratorBuilder builder = lifetime.HasValue + ? sc.AddImageGenerator(services => new TestImageGenerator(), lifetime.Value) + : sc.AddImageGenerator(services => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddKeyedImageGenerator_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + ImageGeneratorBuilder builder = lifetime.HasValue + ? sc.AddKeyedImageGenerator("key", services => new TestImageGenerator(), lifetime.Value) + : sc.AddKeyedImageGenerator("key", services => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.True(sd.IsKeyedService); + Assert.Equal("key", sd.ServiceKey); + Assert.Null(sd.KeyedImplementationInstance); + Assert.NotNull(sd.KeyedImplementationFactory); + Assert.IsType(sd.KeyedImplementationFactory(null!, null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + public class SingletonMiddleware(IImageGenerator inner, IServiceProvider services) : DelegatingImageGenerator(inner) + { + public new IImageGenerator InnerGenerator => base.InnerGenerator; + public IServiceProvider Services => services; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs new file mode 100644 index 00000000000..819fcde88ac --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingImageGeneratorTests +{ + [Fact] + public void LoggingImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new LoggingImageGenerator(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingImageGenerator(new TestImageGenerator(), null!)); + } + + [Fact] + public void UseLogging_AvoidsInjectingNopGenerator() + { + using var innerGenerator = new TestImageGenerator(); + + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(LoggingImageGenerator))); + Assert.Same(innerGenerator, innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(IImageGenerator))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(factory).Build().GetService(typeof(LoggingImageGenerator))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging().Build(services).GetService(typeof(LoggingImageGenerator))); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(null).Build(services).GetService(typeof(LoggingImageGenerator))); + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services).GetService(typeof(LoggingImageGenerator))); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GenerateImagesAsync_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + return Task.FromResult(new ImageGenerationResponse()); + }, + }; + + using IImageGenerator generator = innerGenerator + .AsBuilder() + .UseLogging() + .Build(services); + + await generator.GenerateAsync( + new ImageGenerationRequest("A beautiful sunset"), + new ImageGenerationOptions { ModelId = "dall-e-3" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.True( + entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked:") && + entry.Message.Contains("A beautiful sunset") && + entry.Message.Contains("dall-e-3")), + entry => Assert.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed:", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked.") && !entry.Message.Contains("A beautiful sunset")), + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed.") && !entry.Message.Contains("dall-e-3"))); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GenerateImagesAsync_WithOriginalImages_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + using IImageGenerator generator = innerGenerator + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + + AIContent[] originalImages = [new DataContent((byte[])[1, 2, 3, 4], "image/png")]; + await generator.GenerateAsync( + new ImageGenerationRequest("Make it more colorful", originalImages), + new ImageGenerationOptions { ModelId = "dall-e-3" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.True( + entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked:") && + entry.Message.Contains("Make it more colorful") && + entry.Message.Contains("dall-e-3")), + entry => Assert.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked.") && !entry.Message.Contains("Make it more colorful")), + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed.") && !entry.Message.Contains("dall-e-3"))); + } + else + { + Assert.Empty(logs); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs new file mode 100644 index 00000000000..498b4738962 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public static class SingletonImageGeneratorExtensions +{ + public static ImageGeneratorBuilder UseSingletonMiddleware(this ImageGeneratorBuilder builder) + => builder.Use((inner, services) + => new ImageGeneratorDependencyInjectionPatterns.SingletonMiddleware(inner, services)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index c07f3056054..d06b423a504 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -22,6 +22,7 @@ +