Image Generation

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). The previous pages made DocsAssistant read pictures. This one makes it draw them: a maintainer asks for an illustration for a docs page and the service generates it, stores it under a name the server chooses, and serves it back. It covers the ImageModel API, the options that are portable and the ones that are not, how to pick a provider when more than one starter is present, what to do with the bytes that come back, and the cost, latency and safety questions that image generation adds. The classes were compiled against the 2.0.1 jars and their unit tests were run. The OpenAI and Stability AI calls were made against a local HTTP server that stands in for the provider (no account or key was used), so the request bodies quoted below are what the client sent, and nothing here was generated by a live model.

The ImageModel API

Image generation is a single-purpose model, separate from ChatModel: text goes in, one or more pictures come out. The interface is small.

Table 1. The image API in Spring AI 2.0.1 (org.springframework.ai.image)
Type Role

ImageModel

ImageResponse call(ImagePrompt). A functional interface, so a lambda is a valid fake in tests

ImagePrompt

A list of ImageMessage plus optional ImageOptions. Convenience constructors accept a single string

ImageMessage

A text and a weight. For models that support weights they may be positive or negative

ImageOptions

The portable options: n, model, width, height, responseFormat and style. Build them with ImageOptionsBuilder, or use a provider’s own options class

ImageResponse

The ImageGeneration results plus ImageResponseMetadata (creation time and, when the provider reports it, Usage)

ImageGeneration

One Image (a url or a b64Json string) and provider metadata, such as the revised prompt on OpenAI

From ImageClient to ImageModel

The older name of the interface was ImageClient, and it is the one Mastering Spring AI uses (written for Spring AI 0.8.x). The interface and its companions were renamed to the Model family, and the reference page still keeps the old file name (imageclient.html). Code and snippets that use ImageClient port directly: inject ImageModel, build an ImagePrompt, read the ImageResponse. The transcription counterpart went through the same rename, from OpenAiAudioTranscriptionClient to OpenAiAudioTranscriptionModel. The full map from the books to 2.0 is on Migrating from 1.x to 2.x.

Providers and choosing one

Table 2. Image providers in Spring AI 2.0.1
Provider Starter and property prefix Default model or engine

OpenAI

spring-ai-starter-model-openai, spring.ai.openai.image.*

gpt-image-1-mini in the 2.0.1 jar. OpenAI’s image guide (checked 2026-09-29) documents gpt-image-2.5-sunburst and gpt-image-2.5-flare; the model is a string you can set

Stability AI

spring-ai-starter-model-stability-ai, spring.ai.stabilityai.image.*

stable-diffusion-v1-6, through the api.stability.ai/v1 base URL

Google GenAI

spring-ai-starter-model-google-genai-image, spring.ai.google.genai.image.*

Set with spring.ai.google.genai.image.model

The provider is a property: spring.ai.model.image takes openai, stabilityai, google-genai or none. OpenAI’s image support is included in the OpenAI starter, which also brings chat and audio, so a project with the OpenAI chat starter has an ImageModel bean without asking for one. With both the OpenAI and the Stability AI starters present and no selector, a scratch context created two ImageModel beans (openAiImageModel and stabilityAiImageModel), and a constructor that takes one ImageModel fails; spring.ai.model.image=stabilityai left only stabilityAiImageModel, and openai left only openAiImageModel. The same holds for speech on Audio Transcription & Speech. Sources: Spring AI: OpenAI Image Generation, Spring AI: Stability AI Image Generation and Spring AI: Google GenAI Image Generation.

application.yml: OpenAI images, key from the environment
spring:
  ai:
    model:
      image: openai
    openai:
      api-key: ${OPENAI_API_KEY}
      timeout: 150s          # the default is 60 s (see the latency section)
      image:
        model: gpt-image-2.5-flare
        quality: medium
        size: 1024x1024

Generating an illustration

The service below uses only portable options (size and count), so it works with whichever ImageModel the selector chose. It stores the picture and keeps the revised prompt when the provider reports one.

IllustrationService.java: portable options and a stored result
package com.example.docsassistant;

import java.net.URI;
import java.util.Base64;

import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageGeneration;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.image.ImageOptionsBuilder;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.openai.metadata.OpenAiImageGenerationMetadata;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
class IllustrationService {

    record Illustration(String id, String revisedPrompt) {
    }

    private final ImageModel imageModel;
    private final IllustrationStore store;
    private final RestClient downloads = RestClient.create();

    IllustrationService(ImageModel imageModel, IllustrationStore store) {
        this.imageModel = imageModel;
        this.store = store;
    }

    /** One illustration for a docs page, with options every provider understands: size and count. */
    Illustration illustrate(String subject) {
        ImageOptions options = ImageOptionsBuilder.builder()
                .width(1024)
                .height(1024)
                .n(1)
                .build();
        String prompt = "A flat, minimal vector illustration for a technical documentation page about: " + subject
                + ". No text and no logos in the picture.";
        ImageResponse response = this.imageModel.call(new ImagePrompt(prompt, options));

        ImageGeneration generation = response.getResult();
        String id = this.store.save(bytesOf(generation.getOutput()));
        // OpenAI rewrites some prompts before drawing; keep what was really used, next to the picture
        String revised = generation.getMetadata() instanceof OpenAiImageGenerationMetadata openAi
                ? openAi.getRevisedPrompt() : null;
        return new Illustration(id, revised);
    }

    // Providers answer with base64 data or with a link that expires; store the bytes either way.
    private byte[] bytesOf(Image image) {
        if (image.getB64Json() != null) {
            return Base64.getDecoder().decode(image.getB64Json());
        }
        if (image.getUrl() != null) {
            URI url = URI.create(image.getUrl());
            if (!"https".equals(url.getScheme())) {
                throw new IllegalStateException("refusing to download from " + url.getScheme());
            }
            return this.downloads.get().uri(url).retrieve().body(byte[].class);
        }
        throw new IllegalStateException("the provider returned neither base64 data nor a URL");
    }
}

What the OpenAI client sent in the stand-in check, for a call with the options above and no model configured:

{"prompt":"A flat, minimal vector illustration for a technical documentation page about: chat memory. No text and no logos in the picture.","model":"gpt-image-1-mini","n":1,"size":"1024x1024"}

The portable width and height became size, n went through, and the model came from the class default because none was configured; the request carried no response_format. OpenAI’s image guide reads the result as base64 data for the GPT Image models, and the 2.0.1 model class fills Image.b64Json when the answer has base64 data and Image.url when it has a link, so bytesOf handles both. Older OpenAI models return links unless asked otherwise, and a link that a provider hands out can expire: store the bytes, not the link. The download branch accepts https only for the same reason as the URI rule on Multimodality.

Two smaller points. Prompt text that the caller controls is part of the prompt to a third party, so it is trimmed and length limited in the controller below, and the template around it states what must not appear (text and logos, which image models draw badly). And the caption for the illustration comes from you, not from the model: a picture is not a description of itself, so write the alt text that goes with it.

Provider options

Provider-specific options ride on the same ImagePrompt as the portable ones.

ImageOptionsExamples.java: OpenAI and Stability AI options
package com.example.docsassistant;

import java.util.List;

import org.springframework.ai.image.ImageMessage;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.openai.OpenAiImageOptions;
import org.springframework.ai.stabilityai.StyleEnum;
import org.springframework.ai.stabilityai.api.StabilityAiImageOptions;
import org.springframework.stereotype.Component;

/** Provider-specific options ride on the same ImagePrompt as the portable ones. */
@Component
class ImageOptionsExamples {

    private final ImageModel imageModel;

    ImageOptionsExamples(ImageModel imageModel) {
        this.imageModel = imageModel;
    }

    /** With spring.ai.model.image=openai (the default when only the OpenAI starter is present). */
    ImageResponse openAi(String prompt) {
        OpenAiImageOptions options = OpenAiImageOptions.builder()
                .model("gpt-image-2.5-flare")
                .width(1024)
                .height(1024)
                .quality("medium")
                .user("docsassistant-illustrations")   // an opaque id, helps the provider trace abuse; never an email
                .build();
        return this.imageModel.call(new ImagePrompt(prompt, options));
    }

    /** With spring.ai.model.image=stabilityai: weighted messages, a negative weight steers away from a trait. */
    ImageResponse stability(String subject) {
        StabilityAiImageOptions options = StabilityAiImageOptions.builder()
                .model("stable-diffusion-v1-6")
                .width(512)
                .height(512)
                .steps(30)
                .cfgScale(7f)
                .stylePreset(StyleEnum.DIGITAL_ART)
                .build();
        List<ImageMessage> messages = List.of(
                new ImageMessage("A clean technical diagram of " + subject, 1.0f),
                new ImageMessage("blurry, text, watermark", -1.0f));
        return this.imageModel.call(new ImagePrompt(messages, options));
    }
}

The stand-in server received these bodies:

{"prompt":"cat","model":"gpt-image-2.5-flare","quality":"medium","size":"1024x1024","user":"docsassistant-illustrations"}

{"text_prompts":[{"text":"A clean technical diagram of chat memory","weight":1.0},{"text":"blurry, text, watermark","weight":-1.0}],
 "height":512,"width":512,"cfg_scale":7.0,"steps":30,"style_preset":"digital-art"}

The request for Stability AI went to /v1/generation/stable-diffusion-v1-6/text-to-image on the configured base URL, and the negative weight on the second message is how the API is told to steer away from a trait. The same portable call that worked for OpenAI (1024 by 1024) is sent to Stability AI as height and width of 1024 and samples of 1; the reference says the engine validates dimensions (a multiple of 64, engine-specific limits), so a portable size that suits one provider may be rejected by another engine.

Table 3. Options worth knowing, from the provider references
Option Behaviour

OpenAI n, size, quality

The reference documents n from 1 to 10 (1 for dall-e-3), and quality and style (vivid or natural) for dall-e-3; OpenAI’s guide lists low, medium, high and auto for GPT Image models, with xhigh and max added by the 2.5 models, and sizes as WIDTHxHEIGHT strings whose sides are multiples of 16

OpenAI user

An opaque end-user identifier that helps the provider detect abuse. Use a hash or an internal ID, never an email address

Stability AI n, width, height

n from 1 to 10; width and height in multiples of 64, both 512 by default

Stability AI steps, cfgScale, seed, stylePreset

Diffusion steps (10 to 50, default 30), prompt strictness (0 to 35, default 7), a seed for repeatable output, and a style preset such as digital-art; the StyleEnum constants avoid typos

Stability AI responseFormat

application/json or image/png

The module talks to the v1 engines API of Stability AI. Check that the engine you choose is still served before you build on it; the Stability AI platform documentation lists what is current.

Storing the results

The store gives every picture a random UUID as its name and refuses to load anything that is not a UUID, so nothing the model, the provider or a caller wrote can become part of a path.

IllustrationStore.java: names chosen by the server
package com.example.docsassistant;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/** Keeps generated pictures on disk under names the server chooses. Swap the body for S3 or a database as needed. */
@Component
class IllustrationStore {

    private final Path directory;

    IllustrationStore(@Value("${docsassistant.images.directory:./generated-images}") Path directory)
            throws IOException {
        this.directory = Files.createDirectories(directory).toAbsolutePath().normalize();
    }

    /** The file name is a random UUID; nothing the model, the provider or the user wrote becomes part of the path. */
    String save(byte[] image) {
        String id = UUID.randomUUID().toString();
        try {
            Files.write(this.directory.resolve(id + ".png"), image);
        }
        catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
        return id;
    }

    Optional<byte[]> load(String id) {
        UUID parsed;
        try {
            parsed = UUID.fromString(id);   // rejects "../../etc/passwd" before it can reach the file system
        }
        catch (IllegalArgumentException ex) {
            return Optional.empty();
        }
        Path file = this.directory.resolve(parsed + ".png");
        try {
            return Files.exists(file) ? Optional.of(Files.readAllBytes(file)) : Optional.empty();
        }
        catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }
}

The directory is an example. In production put the bytes in object storage or a database, keep the prompt, the revised prompt, the model, the requesting user and a timestamp next to them, and serve them through a controller that checks who may see them. The controller is where the cost and the audience are controlled.

IllustrationController.java: a maintainer-only endpoint
package com.example.docsassistant;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class IllustrationController {

    record Request(String subject) {
    }

    private final IllustrationService illustrations;
    private final IllustrationStore store;

    IllustrationController(IllustrationService illustrations, IllustrationStore store) {
        this.illustrations = illustrations;
        this.store = store;
    }

    // Every call costs money and produces content under your name: restrict who can trigger it.
    @PostMapping("/api/images/generate")
    @PreAuthorize("hasRole('MAINTAINER')")
    IllustrationService.Illustration generate(@RequestBody Request request) {
        String subject = request.subject() == null ? "" : request.subject().strip();
        if (subject.isEmpty() || subject.length() > 200) {
            throw new IllegalArgumentException("subject must be 1 to 200 characters");
        }
        return this.illustrations.illustrate(subject);
    }

    @GetMapping(path = "/api/images/{id}", produces = MediaType.IMAGE_PNG_VALUE)
    ResponseEntity<byte[]> fetch(@PathVariable String id) {
        return this.store.load(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
    }
}

@PreAuthorize needs @EnableMethodSecurity, as on Tool Calling. Every call to POST /api/images/generate costs money and produces content under the site’s name, which is why it is restricted to maintainers and why the subject is length limited.

Cost, latency and limits

The cost of a GPT Image call is metered in tokens, and OpenAI’s guide tells you to read the response’s usage to measure it. With input tokens, output tokens and the model’s per-token rates and ,

per request, and pictures multiply the output side. Rates and the token count of an image depend on the model, the size and the quality setting, so read them from OpenAI’s pricing page and from ImageResponseMetadata.getUsage() instead of copying a number from a page like this one. Two limits matter more than the price at first:

  • Latency. OpenAI’s guide says complex prompts can take up to two minutes. The Spring AI reference gives spring.ai.openai.timeout a default of 60 seconds, so a slow generation can fail on your side while OpenAI is still working. The configuration above raises it, and the controller should run generation off the request thread (an @Async call or a job queue that reports back), as an interactive endpoint should not hold a servlet thread for two minutes.

  • Access. OpenAI’s guide says GPT Image models may require organisation verification in the developer console before the first call works.

Add rate limits and a per-user or per-day quota to the endpoint; an image endpoint without a quota is an open wallet.

Safety and provenance

  • Moderation. The provider filters prompts and outputs, and OpenAI’s guide documents a moderation parameter and a moderation_blocked error code. Handle a blocked request as an ordinary failure with a clear message. A ModerationModel in front of your own prompts is covered on Security & Guardrails.

  • Prompts are inputs. If the subject comes from user text or from a document, it can carry instructions. Keep the template’s constraints outside the user’s part, limit the length, and do not let a chat model that read untrusted content trigger this endpoint as a tool without a human approval step.

  • Privacy and logging. The prompt goes to a third party. spring.ai.image.observations.log-prompt logs it as well; see Observability for the flags and what they expose.

  • Provenance and rights. Say on the page that an image is AI generated, write its alt text yourself, and avoid prompts that ask for a living person’s likeness, a trademarked character or another artist’s style. What the terms of the provider allow for commercial use is the provider’s to say; read them.

Testing

ImageModel is a functional interface, so the fake is a lambda. The test below checks the two things that matter without calling a provider: that the base64 result is decoded and stored under a server-chosen ID, and that the request asks for the portable size.

IllustrationServiceTest.java
package com.example.docsassistant;

import static org.assertj.core.api.Assertions.assertThat;

import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageGeneration;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;

class IllustrationServiceTest {

    @TempDir
    Path directory;

    @Test
    void storesTheBase64ImageAndAsksForAPortableSize() throws IOException {
        List<ImagePrompt> seen = new ArrayList<>();
        ImageModel fake = prompt -> {
            seen.add(prompt);
            String b64 = Base64.getEncoder().encodeToString("png-bytes".getBytes());
            return new ImageResponse(List.of(new ImageGeneration(new Image(null, b64))));
        };
        var store = new IllustrationStore(this.directory);
        var service = new IllustrationService(fake, store);

        var illustration = service.illustrate("chat memory");

        assertThat(store.load(illustration.id())).contains("png-bytes".getBytes());
        assertThat(illustration.revisedPrompt()).isNull();
        assertThat(seen.get(0).getOptions().getWidth()).isEqualTo(1024);
        assertThat(seen.get(0).getInstructions().get(0).getText()).contains("chat memory");
    }
}
IllustrationStoreTest.java: a path-traversal id is refused
package com.example.docsassistant;

import static org.assertj.core.api.Assertions.assertThat;

import java.io.IOException;
import java.nio.file.Path;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class IllustrationStoreTest {

    @TempDir
    Path directory;

    @Test
    void roundTripsUnderAServerChosenName() throws IOException {
        var store = new IllustrationStore(this.directory);

        String id = store.save("png".getBytes());

        assertThat(store.load(id)).contains("png".getBytes());
    }

    @Test
    void refusesAnythingThatIsNotAnId() throws IOException {
        var store = new IllustrationStore(this.directory);

        assertThat(store.load("../../etc/passwd")).isEmpty();
        assertThat(store.load("not-a-uuid")).isEmpty();
    }
}

To see the exact request an OpenAiImageModel or a StabilityAiImageModel sends, point spring.ai.openai.base-url or spring.ai.stabilityai.base-url at a local HTTP server that logs the body, which is how the bodies above were captured. More on this on Testing.

Where to go next