DEV Community

Cover image for 4 Wiring Mistakes That Break Chat Image Generation in .NET 10
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

4 Wiring Mistakes That Break Chat Image Generation in .NET 10

The first time I wired image generation into a chat pipeline with Microsoft.Extensions.AI 10.10, I typed "generate an image of a teal hexagon" and got back an assistant message with empty text and no picture. No exception. No warning, because I had suppressed the experimental MEAI001 diagnostic like everyone does. The image generator's call counter said 0.

The API itself is small. There are three pieces:

  • IImageGenerator does the actual work (prompt in, image bytes or URIs out).
  • HostedImageGenerationTool goes in ChatOptions.Tools and says "images are allowed in this conversation."
  • UseImageGeneration() adds ImageGeneratingChatClient, which swaps that marker for real functions the model can call: GenerateImage, EditImage and GetImagesForEdit.

Each piece has a way to be wired wrong that still compiles. Here are the four I hit, what each one looks like at runtime, and how I catch it in a test. The code is skeleton code in the places that matter, and every omission is marked. The complete working, tested project (61 offline tests, no API key needed) is in Tech Skill Builder.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.

Mistake 1: The middleware is in the wrong order

This was my empty-reply bug. Both versions build:

// Broken: function invocation is outside the image middleware
IChatClient broken = new ChatClientBuilder(model)
    .UseFunctionInvocation()
    .UseImageGeneration(imageGenerator)
    .Build();

// Works: image middleware outside, function invocation inside
IChatClient chat = new ChatClientBuilder(model)
    .UseImageGeneration(imageGenerator)
    .UseFunctionInvocation()
    .Build();
Enter fullscreen mode Exit fullscreen mode

With ChatClientBuilder, the first Use is the outermost layer. The image middleware has to sit outside function invocation, because it's the layer that turns the marker into GenerateImage and friends. The function invoker underneath it then sees real AIFunctions it can run against your IImageGenerator.

Flip them and the function invoker on the outside never gets a function it can execute. I ran both orders against the same offline fakes. The broken one called the generator 0 times, returned empty text, and the only content in the response was an ImageGenerationToolCallContent: the model asked for a picture and nobody answered. Leaving out UseFunctionInvocation() entirely gives the same result.

Catch it: assert that the generator was actually called. A test that only checks "no exception" passes on the broken pipeline.

Mistake 2: Treating the marker as the feature (and leaving it on everywhere)

HostedImageGenerationTool doesn't generate anything. It isn't even an AIFunction. It's a marker named image_generation that can carry default ImageGenerationOptions. That has two consequences.

First, it's a permission switch, so treat it like one. If half your traffic is plain Q&A, don't attach it to every request. Second, the options on it are the place to clamp what the model is allowed to ask for:

static ChatOptions CreateOptions(bool allowImages, int requestedCount)
{
    var options = new ChatOptions { Instructions = "You are a helpful assistant." };
    if (!allowImages)
        return options;

    options.Tools =
    [
        new HostedImageGenerationTool
        {
            Options = new ImageGenerationOptions
            {
                MediaType = "image/png",
                Count = Math.Clamp(requestedCount, 1, 4), // your CDN bill, not the model, decides
                ResponseFormat = ImageGenerationResponseFormat.Data,
            },
        },
    ];
    return options;
}
// size limits, per-tenant policy, prompt refusal rules:
// full implementation in the complete project
Enter fullscreen mode Exit fullscreen mode

Models will happily accept "make me 20 variations." Your disk and your bill will not.

Mistake 3: Pushing pixels back into the context window

Images are huge compared to tokens. On turn three of a conversation, you don't want every earlier PNG sent back to the model as raw bytes. ImageGeneratingChatClient has a setting for exactly this, DataContentHandling:

  • AllImages (the default): every image in the history reaches the model as a short text placeholder with an image id (it contains meai_image:), not as bytes.
  • GeneratedImages: only images the pipeline generated are replaced. Images the user uploaded still go through, which you want if the model should actually look at a reference photo.
  • None: everything passes through. Only for a real multimodal model and a budget to match.

If you need to set it explicitly, build the layers by hand:

IChatClient functionLayer = new ChatClientBuilder(model)
    .UseFunctionInvocation()
    .Build();

IChatClient chat = new ImageGeneratingChatClient(
    functionLayer,
    imageGenerator,
    ImageGeneratingChatClient.DataContentHandling.AllImages);
Enter fullscreen mode Exit fullscreen mode

Same order as Mistake 1, just written inside-out.

The placeholder isn't decoration. EditImage takes an imageId, and those ids are how "now add diagonal stripes" finds the right picture. That's where the second half of this mistake hides: if you run a chat reducer or summarizer over long conversations and it drops the placeholder lines, edits on later turns lose their target. Make sure whatever trims your history keeps them.

Catch it: send a user message that contains a PNG DataContent through the pipeline with a capturing inner client, and assert the inner client received text with meai_image: and no DataContent.

Mistake 4: Looking for the picture in the wrong place

This is the sneakiest one, because the image is there all along. After a successful generate turn, these were the contents of the response in my check:

ImageGenerationToolCallContent
ImageGenerationToolResultContent
TextContent   (the assistant's "your image is ready" reply)
Enter fullscreen mode Exit fullscreen mode

Zero top-level DataContent items. The middleware rewrites the function call and result into image-specific content types, and the PNG lives inside the result's Outputs. A mapper that grabs DataContent from the last message returns nothing, and the UI shows a confident "your image is ready" with no image.

static IEnumerable<DataContent> FindImages(ChatResponse response)
{
    foreach (var content in response.Messages.SelectMany(m => m.Contents))
    {
        if (content is ImageGenerationToolResultContent result)
        {
            foreach (var image in (result.Outputs ?? []).OfType<DataContent>())
                yield return image;
        }
    }
    // media-type checks, top-level DataContent from multimodal models,
    // saving to disk + base64 for previews: full implementation in the complete project
}
Enter fullscreen mode Exit fullscreen mode

The test that would have saved me an afternoon

You don't need an API key or a real image model to test any of this. Test the protocol, not the pixels. With an offline IChatClient that emits a GenerateImage call from keywords, and an offline IImageGenerator that returns deterministic PNG bytes, the core test is short:

[Fact]
public async Task Generate_turn_runs_the_generator_and_returns_an_image()
{
    using var model = new OfflineImageAwareChatClient(); // emits GenerateImage, no API key
    using var generator = new OfflineImageGenerator();   // same prompt, same bytes
    using IChatClient chat = new ChatClientBuilder(model)
        .UseImageGeneration(generator)
        .UseFunctionInvocation()
        .Build();

    var response = await chat.GetResponseAsync(
        "generate an image of a teal hexagon",
        CreateOptions(allowImages: true, requestedCount: 1));

    Assert.Equal(1, generator.CallCount);   // Mistake 1
    Assert.Single(FindImages(response));     // Mistake 4
}
// the two offline fakes, DataContentHandling, edit-flow and API tests:
// in the complete project
Enter fullscreen mode Exit fullscreen mode

Swap the two Use lines and the first assert fails. Break the mapper and the second one does. Two companion tests cover the rest: with no marker in ChatOptions.Tools the generator count stays at 0 (Mistake 2), and with AllImages an incoming PNG reaches the inner client as a placeholder (Mistake 3).

My checklist before shipping

  • [ ] UseImageGeneration() comes before UseFunctionInvocation() in the builder.
  • [ ] A test asserts the generator was called, not just that nothing threw.
  • [ ] HostedImageGenerationTool is attached only where images are allowed, with Count and size clamped server-side.
  • [ ] DataContentHandling stays at AllImages unless I have a reason.
  • [ ] My history reducer keeps the meai_image: placeholders.
  • [ ] My mapper reads ImageGenerationToolResultContent.Outputs.
  • [ ] MEAI001 is suppressed in one place (the project file) with a note, not with pragmas sprinkled everywhere.
  • [ ] Provider keys live in user-secrets or a key vault, never in appsettings.json.

Get the complete project

The snippets above show where things break. The full .NET 10 solution has all of it working end to end: a Core library with an offline IImageGenerator that returns deterministic PNGs, an offline tool-calling chat client, the pipeline factory, an artifact store and chat service with DI, a minimal API (/api/chat, /api/generate, /api/images), a CLI demo, and 61 passing tests that run without an API key. When you're ready, you swap in your real provider and the middleware stays the same. It's in Tech Skill Builder, along with a growing library of other complete, tested .NET + AI projects.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. 👉 Join Tech Skill Builder

Are you putting image generation inside the chat box, or keeping it behind a separate "Generate" button? I'd like to hear which one your users actually prefer.

Top comments (0)