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