diff --git a/readme.md b/readme.md index ab94165..e128fb7 100644 --- a/readme.md +++ b/readme.md @@ -250,6 +250,59 @@ var options = new GrokChatOptions ``` Learn more about [Remote MCP tools](https://docs.x.ai/docs/guides/tools/remote-mcp-tools). + +## Image Generation + +Grok also supports image generation using the `IImageGenerator` abstraction from +Microsoft.Extensions.AI. Use the `AsIImageGenerator` extension method to get an +image generator client: + +```csharp +var imageGenerator = new GrokClient(Environment.GetEnvironmentVariable("XAI_API_KEY")!) + .AsIImageGenerator("grok-imagine-image-beta"); + +var request = new ImageGenerationRequest("A cat sitting on a tree branch"); +var options = new ImageGenerationOptions +{ + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 1 +}; + +var response = await imageGenerator.GenerateAsync(request, options); + +var image = (UriContent)response.Contents.First(); +Console.WriteLine($"Generated image URL: {image.Uri}"); +``` + +### Editing Images + +You can also edit previously generated images by passing them as input to a new +generation request: + +```csharp +var imageGenerator = new GrokClient(Environment.GetEnvironmentVariable("XAI_API_KEY")!) + .AsIImageGenerator("grok-imagine-image-beta"); + +// First, generate the original image +var request = new ImageGenerationRequest("A cat sitting on a tree branch"); +var options = new ImageGenerationOptions +{ + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 1 +}; + +var response = await imageGenerator.GenerateAsync(request, options); +var image = (UriContent)response.Contents.First(); + +// Now edit the image by providing it as input along with the edit instructions +var edit = await imageGenerator.GenerateAsync( + new ImageGenerationRequest("Edit provided image by adding a batman mask", [image]), + options); + +var editedImage = (UriContent)edit.Contents.First(); +Console.WriteLine($"Edited image URL: {editedImage.Uri}"); +``` + # xAI.Protocol diff --git a/src/xAI.Tests/ImageGeneratorTests.cs b/src/xAI.Tests/ImageGeneratorTests.cs new file mode 100644 index 0000000..5dfd103 --- /dev/null +++ b/src/xAI.Tests/ImageGeneratorTests.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.AI; +using Tests.Client.Helpers; +using xAI; +using static ConfigurationExtensions; + +namespace xAI.Tests; + +public class ImageGeneratorTests(ITestOutputHelper output) +{ + [SecretsFact("XAI_API_KEY")] + public async Task GenerateImage_WithPrompt_ReturnsImageContent() + { + var imageGenerator = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsIImageGenerator("grok-imagine-image-beta"); + + var request = new ImageGenerationRequest("A cat sitting on a tree branch"); + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 1 + }; + + var response = await imageGenerator.GenerateAsync(request, options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + + var image = response.Contents.First(); + Assert.True(image is UriContent); + + var uriContent = (UriContent)image; + Assert.NotNull(uriContent.Uri); + + output.WriteLine($"Generated image URL: {uriContent.Uri}"); + } + + [SecretsFact("XAI_API_KEY")] + public async Task GenerateImage_WithEditsToPreviousImage() + { + var imageGenerator = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsIImageGenerator("grok-imagine-image-beta"); + + var request = new ImageGenerationRequest("A cat sitting on a tree branch"); + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 1 + }; + + var response = await imageGenerator.GenerateAsync(request, options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + var image = Assert.IsType(response.Contents.First()); + output.WriteLine($"Generated image URL: {image.Uri}"); + + var edit = await imageGenerator.GenerateAsync(new ImageGenerationRequest("Edit provided image by adding a batman mask", [image]), options); + + Assert.NotNull(edit); + Assert.NotEmpty(edit.Contents); + Assert.Single(edit.Contents); + image = Assert.IsType(edit.Contents.First()); + + output.WriteLine($"Edited image URL: {image.Uri}"); + } + + [SecretsFact("XAI_API_KEY")] + public async Task GenerateImage_WithBase64Response_ReturnsDataContent() + { + var imageGenerator = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsIImageGenerator("grok-2-image"); + + var request = new ImageGenerationRequest("A sunset over mountains"); + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 1 + }; + + var response = await imageGenerator.GenerateAsync(request, options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + + var image = response.Contents.First(); + Assert.True(image is DataContent); + + var dataContent = (DataContent)image; + Assert.True(dataContent.Data.Length > 0); + Assert.Equal("image/jpeg", dataContent.MediaType); + + output.WriteLine($"Generated image size: {dataContent.Data.Length} bytes"); + } + + [SecretsFact("XAI_API_KEY")] + public async Task GenerateMultipleImages_ReturnsCorrectCount() + { + var imageGenerator = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsIImageGenerator("grok-2-image"); + + var request = new ImageGenerationRequest("A robot reading a book"); + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 3 + }; + + var response = await imageGenerator.GenerateAsync(request, options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Equal(3, response.Contents.Count); + + foreach (var image in response.Contents) + { + Assert.True(image is UriContent); + output.WriteLine($"Image URL: {((UriContent)image).Uri}"); + } + } + + [SecretsFact("XAI_API_KEY")] + public async Task GenerateImage_ResponseContainsRawRepresentation() + { + var imageGenerator = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsIImageGenerator("grok-2-image"); + + var request = new ImageGenerationRequest("A futuristic cityscape"); + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri + }; + + var response = await imageGenerator.GenerateAsync(request, options); + + Assert.NotNull(response); + Assert.NotNull(response.RawRepresentation); + + // The raw representation should be an ImageResponse from the protocol + var rawResponse = Assert.IsType(response.RawRepresentation); + Assert.NotNull(rawResponse.Model); + output.WriteLine($"Model used: {rawResponse.Model}"); + } + + [Fact] + public async Task GenerateImage_WithNullRequest_ThrowsArgumentNullException() + { + var imageGenerator = new GrokClient("test-api-key") + .AsIImageGenerator("grok-2-image"); + + await Assert.ThrowsAsync( + async () => await imageGenerator.GenerateAsync(null!, null)); + } + + [Fact] + public async Task GenerateImage_WithNullPrompt_ThrowsArgumentNullException() + { + var imageGenerator = new GrokClient("test-api-key") + .AsIImageGenerator("grok-2-image"); + + var request = new ImageGenerationRequest(null!); + + await Assert.ThrowsAsync( + async () => await imageGenerator.GenerateAsync(request, null)); + } + + [Fact] + public void GetService_ReturnsImageGeneratorMetadata() + { + var imageGenerator = new GrokClient("test-api-key") + .AsIImageGenerator("grok-2-image"); + + var metadata = imageGenerator.GetService(); + + Assert.NotNull(metadata); + Assert.Equal("xai", metadata.ProviderName); + Assert.Equal("grok-2-image", metadata.DefaultModelId); + } +} diff --git a/src/xAI/GrokClientExtensions.cs b/src/xAI/GrokClientExtensions.cs index 49910d7..9bf53fb 100644 --- a/src/xAI/GrokClientExtensions.cs +++ b/src/xAI/GrokClientExtensions.cs @@ -15,4 +15,12 @@ public static IChatClient AsIChatClient(this GrokClient client, string defaultMo /// Creates a new from the specified using the given model as the default. public static IChatClient AsIChatClient(this Chat.ChatClient client, string defaultModelId) => new GrokChatClient(client, defaultModelId); + + /// Creates a new from the specified using the given model as the default. + public static IImageGenerator AsIImageGenerator(this GrokClient client, string defaultModelId) + => new GrokImageGenerator(client.Channel, client.Options, defaultModelId); + + /// Creates a new from the specified using the given model as the default. + public static IImageGenerator AsIImageGenerator(this Image.ImageClient client, string defaultModelId) + => new GrokImageGenerator(client, defaultModelId); } \ No newline at end of file diff --git a/src/xAI/GrokImageGenerator.cs b/src/xAI/GrokImageGenerator.cs new file mode 100644 index 0000000..88ae984 --- /dev/null +++ b/src/xAI/GrokImageGenerator.cs @@ -0,0 +1,144 @@ +using Grpc.Net.Client; +using Microsoft.Extensions.AI; +using xAI.Protocol; +using static xAI.Protocol.Image; + +namespace xAI; + +/// +/// Represents an for xAI's Grok image generation service. +/// +sealed class GrokImageGenerator : IImageGenerator +{ + const string DefaultInputContentType = "image/png"; + const string DefaultOutputContentType = "image/jpeg"; + + readonly ImageGeneratorMetadata metadata; + readonly ImageClient imageClient; + readonly string defaultModelId; + readonly GrokClientOptions clientOptions; + + internal GrokImageGenerator(GrpcChannel channel, GrokClientOptions clientOptions, string defaultModelId) + : this(new ImageClient(channel), clientOptions, defaultModelId) + { } + + /// + /// Test constructor. + /// + internal GrokImageGenerator(ImageClient imageClient, string defaultModelId) + : this(imageClient, new(), defaultModelId) + { } + + GrokImageGenerator(ImageClient imageClient, GrokClientOptions clientOptions, string defaultModelId) + { + this.imageClient = imageClient; + this.clientOptions = clientOptions; + this.defaultModelId = defaultModelId; + metadata = new ImageGeneratorMetadata("xai", clientOptions.Endpoint, defaultModelId); + } + + /// + public async Task GenerateAsync( + ImageGenerationRequest request, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var protocolRequest = new GenerateImageRequest + { + Prompt = Throw.IfNull(Throw.IfNull(request).Prompt, "request.Prompt"), + Model = options?.ModelId ?? defaultModelId, + }; + + if (options?.Count is { } count) + protocolRequest.N = count; + + if (options?.ResponseFormat is { } responseFormat) + { + protocolRequest.Format = responseFormat switch + { + ImageGenerationResponseFormat.Uri => ImageFormat.ImgFormatUrl, + ImageGenerationResponseFormat.Data => ImageFormat.ImgFormatBase64, + _ => throw new ArgumentException($"Unsupported response format: {responseFormat}", nameof(options)) + }; + } + + // Handle image editing if original images are provided + if (request.OriginalImages is not null && request.OriginalImages.Any()) + { + var originalImage = request.OriginalImages.FirstOrDefault(); + if (originalImage is DataContent dataContent) + { + var imageUrl = dataContent.Uri?.ToString(); + if (imageUrl == null && dataContent.Data.Length > 0) + imageUrl = $"data:{dataContent.MediaType ?? DefaultInputContentType};base64,{Convert.ToBase64String(dataContent.Data.ToArray())}"; + + if (imageUrl != null) + { + protocolRequest.Image = new ImageUrlContent + { + ImageUrl = imageUrl + }; + } + } + else if (originalImage is UriContent uriContent) + { + protocolRequest.Image = new ImageUrlContent + { + ImageUrl = uriContent.Uri.ToString() + }; + } + } + + var response = await imageClient.GenerateImageAsync(protocolRequest, cancellationToken: cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(response, options?.MediaType); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch + { + Type t when t == typeof(ImageGeneratorMetadata) => metadata, + Type t when t == typeof(GrokImageGenerator) => this, + _ => null + }; + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IImageGenerator interface. + } + + /// + /// Converts an xAI to a . + /// + static ImageGenerationResponse ToImageGenerationResponse(ImageResponse response, string? mediaType) + { + var contents = new List(); + var contentType = mediaType ?? DefaultOutputContentType; // xAI returns JPG by default + + foreach (var image in response.Images) + { + switch (image.ImageCase) + { + case GeneratedImage.ImageOneofCase.Base64: + { + var imageBytes = Convert.FromBase64String(image.Base64); + contents.Add(new DataContent(imageBytes, contentType)); + break; + } + case GeneratedImage.ImageOneofCase.Url: + { + contents.Add(new UriContent(new Uri(image.Url), contentType)); + break; + } + default: + throw new InvalidOperationException("Generated image does not contain a valid URL or base64 data."); + } + } + + return new ImageGenerationResponse(contents) + { + RawRepresentation = response, + }; + } +}