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.
| Type | Role |
|---|---|
|
|
|
A list of |
|
A text and a weight. For models that support weights they may be positive or negative |
|
The portable options: |
|
The |
|
One |
Source: Spring AI: Image Model API.
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
| Provider | Starter and property prefix | Default model or engine |
|---|---|---|
OpenAI |
|
|
Stability AI |
|
|
Google GenAI |
|
Set with |
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.
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
Source: Spring AI: OpenAI Image Generation.
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.
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");
}
}
Source: Spring AI: Image Model API.
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"}
Source: Spring AI: OpenAI Image Generation.
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.
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.
| Option | Behaviour |
|---|---|
OpenAI |
The reference documents |
OpenAI |
An opaque end-user identifier that helps the provider detect abuse. Use a hash or an internal ID, never an email address |
Stability AI |
|
Stability AI |
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 |
Stability AI |
|
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.
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);
}
}
}
Source: Spring AI: Image Model API.
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.
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());
}
}
Source: Spring AI: Image Model API.
@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.timeouta 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@Asynccall 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
moderationparameter and amoderation_blockederror code. Handle a blocked request as an ordinary failure with a clear message. AModerationModelin 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-promptlogs 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.
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");
}
}
Source: Spring AI: Image Model API.
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();
}
}
Source: Spring AI: Image Model API.
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
-
Multimodality sends pictures to a chat model, the reverse direction.
-
Observability records image calls with the other model calls.
-
Production & Deployment covers timeouts, quotas and retries for slow calls.