Versions, and an honest limit. Spring Boot 4.1.2 and Spring AI 2.0.2 (both on Maven Central since 2026-10-22), AWS SDK for Java 2.57.5, Testcontainers 2.0.5 and Java 25. The code is theimage-genmodule of asmhatre/spring-ai, and every console block below is quoted from a file under itsoutput/directory. I did not call any image provider. There are no API keys in my build environment, so the tests use a hand-writtenImageModelthat returns what it is told to, a hand-writtenModerationModel, and, for the wire tests, the realOpenAiImageModeltalking to a local mock that returns a fixed 64×64 picture whatever the prompt says. Nothing here measures image quality, speed, or whether a real model would refuse a prompt. The storage is real: the bucket tests run against a MinIO server in Docker through Testcontainers. Prices were read from the providers’ pages on 2026-10-28 and are copied, not tested.
An image model is a function from a prompt to bytes
Spring AI gives image generation the same shape it gives chat: a small interface, a request object and a response object. The interface isImageModel and it has exactly one abstract method, call. You hand it an ImagePrompt (some text, plus an options object) and you get back an ImageResponse whose results each hold an Image: either a URL to download, or the picture itself as a base64 string.
That is the whole library surface you need for generation. The diagram shows where it sits. Only the blue box is Spring AI. Before it, someone has to decide whether this user may ask for this prompt; after it, someone has to decide whether the bytes that came back are safe to keep. The rest of this article builds the green boxes and shows what each one catches.
01-api-shape.txt).
interface : org.springframework.ai.image.ImageModel
single method : [call]
request : org.springframework.ai.image.ImagePrompt
OpenAI : org.springframework.ai.openai.OpenAiImageModel
Stability AI : org.springframework.ai.stabilityai.StabilityAiImageModel
moderation interface : org.springframework.ai.moderation.ModerationModel
moderation OpenAI : org.springframework.ai.openai.OpenAiModerationModel
Two things in that block matter later. The OpenAI and Stability implementations live in separate artifacts (spring-ai-openai, spring-ai-stability-ai), and so does Google’s (spring-ai-google-genai-image); switching provider is a dependency change plus a different options class, not only a property. And ModerationModel is a second, separate interface. Nothing in ImageModel calls it. You do.
Going deeper: the dependencies, and the model you get by default
The module’s pom.xml declares all three image artifacts so one test class can name their options side by side. A real service would pick one.
<dependencies>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-model</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-client-chat</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-stability-ai</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-google-genai-image</artifactId></dependency>
<dependency><groupId>software.amazon.awssdk</groupId><artifactId>s3</artifactId></dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>If you set nothing, OpenAiImageOptions.DEFAULT_IMAGE_MODEL is gpt-image-1-mini in 2.0.2 and OpenAiModerationOptions.DEFAULT_MODERATION_MODEL is omni-moderation-latest (both printed in 01-api-shape.txt). The Stability client’s defaults are an older stable-diffusion-v1-6 engine at https://api.stability.ai/v1; the next sections come back to why that matters for cost.
- ApiShapeTest is the test that prints the names and defaults, if you want to check them against another Spring AI version.
- Spring AI reference: Image Model API
Writing the test first: an ImageModel you can script
The first practical problem is that a real image call costs money, takes several seconds, and returns something different each time. None of that is what you want in a test of your own code. BecauseImageModel has one method, you can write a fake in a few lines: it returns the responses it was given, in order, and records every prompt it receives so a test can look at the options that arrived.
@Override
public ImageResponse call(ImagePrompt prompt) {
seen.add(prompt);
if (script.isEmpty()) {
throw new IllegalStateException("ScriptedImageModel has no response left for call #" + seen.size());
}
ImageResponse next = script.poll();
if (next == null) {
throw throwables.poll();
}
return next;
}
The fake is ScriptedImageModel.java. A test queues a response with thenB64(bytes), thenUrl(url) or thenThrow(e). There is no randomness and no network, so a failing assertion always means your code changed.
Everything that follows that is not about the wire format uses this fake. The wire tests are the exception, and the next section is the reason they exist.
What the real OpenAiImageModel puts on the wire
A fake tells you your code is correct against your assumptions. To check the assumptions I pointed the realOpenAiImageModel at a local mock of POST /v1/images/generations and recorded each request body. The mock returns the same small picture for every prompt and judges nothing, so this checks what Spring AI sends, not what a provider would do with it.
private static OpenAiImageModel model(String defaultModel) {
return OpenAiImageModel.builder().options(OpenAiImageOptions.builder()
.baseUrl(server.baseUrl()).apiKey("test-key-not-real").model(defaultModel).maxRetries(0).build()).build();
}
The helper is in OpenAiWireTest.java. Here are the first five requests it made (file: 02-wire-requests.txt). The mock is configured with the model name gpt-image-test as the default, so a different name in a body means the default was lost.
1. ImagePrompt("a red bicycle"), model default only
POST /v1/images/generations
{"prompt":"a red bicycle","model":"gpt-image-test"}
2. OpenAiImageOptions size=1536x1024 quality=high n=2
{"prompt":"a red bicycle","model":"gpt-image-1-mini","n":2,"quality":"high","size":"1536x1024"}
3. portable ImageOptionsBuilder width=1024 height=1536
{"prompt":"a red bicycle","model":"gpt-image-test","size":"1024x1536"}
4. OpenAiImageOptions user=user-8f2c
{"prompt":"a red bicycle","model":"gpt-image-1-mini","user":"user-8f2c"}
5. OpenAiImageOptions responseFormat=b64_json
{"prompt":"a red bicycle","model":"gpt-image-1-mini","response_format":"b64_json"}
Read the bodies in order, because the second and fourth are the trap. The default model was set on the model object, but a per-call OpenAiImageOptions that leaves model unset sent gpt-image-1-mini, the library default, not gpt-image-test. The third request, which used the portable options builder, kept the configured model. So the same intention, “make this one 1536×1024”, can silently change which model you are billed for, depending on which options class you reach for.
Set the model on every per-call options object. In 2.0.2 anTwo smaller traps come from the sixth case (file:OpenAiImageOptionspassed toImagePromptdoes not inherit the model configured on theOpenAiImageModel; it replaces it withOpenAiImageOptions.DEFAULT_IMAGE_MODEL. If you configured a more expensive or more capable model for quality, a bare.size(...)call quietly downgrades it. The code in this module sets the model in one place (shown below) so it cannot be forgotten. I observed this on the wire; I did not read the merge code to find which line causes it. The earlier providers article found the same kind of reset for chat options.
02-wire-requests.txt). A prompt built from a list of ImageMessages, which is how you would express a weighted positive and negative prompt, behaves in two ways I did not expect:
6a. ImagePrompt(List<ImageMessage>) with no options: IllegalArgumentException: image options cannot be null
6b. same two messages (weights 1.0 and -1.0) with empty OpenAiImageOptions
{"prompt":"a red bicycle","model":"gpt-image-1-mini"}
The list-only constructor new ImagePrompt(List<ImageMessage>) leaves the options null, and the OpenAI model throws IllegalArgumentException: image options cannot be null. And once options are supplied, the request carries “prompt”: “a red bicycle” only: the second message, “no people” with weight -1.0, never reached the wire. I looked only at the OpenAI path. The weight on ImageMessage exists because some providers take weighted prompts, and Stability’s request class has a TextPrompts type, but I did not run that provider, so I do not claim it uses weights correctly.
Going deeper: the options each provider exposes, and which ones are portable
The portable ImageOptions interface has six getters: getN, getModel, getWidth, getHeight, getResponseFormat and getStyle. Everything else is provider-specific, as the test output shows (the full listing is in 01-api-shape.txt):
portable ImageOptions getters : [getHeight, getModel, getN, getResponseFormat, getStyle, getWidth]
OpenAiImageOptions getters : [getApiKey, getBaseUrl, getCredential, getCustomHeaders, getDeploymentName, getHeight, getMaxRetries, getMicrosoftDeploymentName, getMicrosoftFoundryServiceVersion, getModel, getN, getOrganizationId, getProxy, getQuality, getResponseFormat, getSize, getStyle, getTimeout, getUser, getWidth, isGitHubModels, isMicrosoftFoundry]
StabilityAiImageOptions : [getCfgScale, getClipGuidancePreset, getHeight, getModel, getN, getResponseFormat, getSampler, getSeed, getSteps, getStyle, getStylePreset, getWidth]
GoogleGenAiImageOptions : [getAspectRatio, getHeight, getImageSize, getLabels, getMaxOutputTokens, getModel, getN, getOutputCompressionQuality, getOutputMimeType, getPersonGeneration, getResponseFormat, getSafetyFilterLevel, getSeed, getStyle, getTemperature, getTopK, getTopP, getWidth]So size, quality and user exist only on OpenAiImageOptions; the portable way to ask for a shape is width and height, which the OpenAI model turned into "size":"1024x1536" in request 3 above. Stability has steps, cfgScale, seed, sampler and stylePreset; Google has aspectRatio, imageSize, safetyFilterLevel, personGeneration and outputMimeType. If your code is meant to survive a provider change, keep the choice of options in one class (like OpenAiOptionsFactory) and keep the rest of the service on the portable interface.
- OpenAI’s guide says the Images API
userparameter is deprecated in favour ofsafety_identifier.OpenAiImageOptionsin 2.0.2 hasgetUserand no safety-identifier getter (see the listing above), souseris what you can set; existing requests using it remain supported according to that page. - OpenAI: Image generation guide
Sizes and quality: ask for what the provider can actually do
Quality and size are where cost is decided, and also where the error messages come late. A bad size reaches the provider and comes back as an SDK exception after the network round trip. It is cheaper, and gives a better message, to refuse it before the call. So the first piece of our own code is a small factory that checks the caller’s choices against a list and then builds the options, with the model set explicitly: @Override
public ImageOptions apply(ImageRequest r) {
String size = r.size() == null ? "1024x1024" : r.size();
String quality = r.quality() == null ? "medium" : r.quality();
if (!SIZES.contains(size)) {
throw new RejectedException("bad-size", size + " not in " + new java.util.TreeSet<>(SIZES));
}
if (!QUALITIES.contains(quality)) {
throw new RejectedException("bad-quality", quality + " not in " + new java.util.TreeSet<>(QUALITIES));
}
if (r.n() < 1 || r.n() > 4) {
throw new RejectedException("bad-n", "n must be 1..4");
}
return OpenAiImageOptions.builder().model(model).size(size).quality(quality).n(r.n()).build();
}
The code is in OpenAiOptionsFactory.java. The three sizes and three quality levels are the ones OpenAI’s guide lists for the GPT Image models (1024x1024, 1536x1024, 1024x1536; low, medium, high). Two cautions about treating that as the whole truth. On 2026-10-28 the same guide said gpt-image-2 accepts any size that meets some constraints (both edges multiples of 16, the long edge at most 3840 pixels, at most a 3:1 ratio, between 655,360 and 8,294,400 total pixels), and that the two newest models add xhigh and max quality. Spring AI’s size and quality options are plain strings, so those values can be passed through; I did not send them, and I did not test them against any server. An allow-list is a decision about your product (“we offer three sizes”), not a statement about what the provider supports.
The refusals that come out of that factory, and the ones from the next section, are in 13-refusals.txt. They are all RejectedExceptions with a stable reason code, which is what you map to an HTTP 400 and a message the user can act on:
size not in list -> bad-size: 9999x9999 not in [1024x1024, 1024x1536, 1536x1024]
quality not in list -> bad-quality: ultra not in [high, low, medium]
Going deeper: n greater than one, and the response you get back
The factory allows n from 1 to 4 because every extra image is a separate charge and a separate file to verify. When the provider returns several, ImageResponse.getResults() has several entries; getResult() is the first. What each entry holds is shown by the wire test (file: 03-wire-response.txt):
results : 1
Image.getUrl() : null
Image.getB64Json() len : 10500 chars
metadata type : OpenAiImageGenerationMetadata
revised prompt : a rewritten prompt
response created : 1760000000
response usage : EmptyUsage, total tokens 0 (the mock body carried usage.total_tokens=1068)
URL mode: Image.getUrl() = http://127.0.0.1:43039/files/ok.png
URL mode: Image.getB64Json() = nullThree observations. The result is either base64 or a URL, never both, so your code needs both branches. The metadata type is OpenAI-specific and carries the provider’s rewritten prompt, if it sends one (OpenAI’s guide documents revised_prompt for the Responses API image tool; I could not confirm which of the Images API models fill it, and my mock fills it unconditionally). And getUsage() is an EmptyUsage with zero tokens even though the mock’s body carried a usage block. The class that builds the response metadata, OpenAiImageResponseMetadata.from(ImagesResponse), has a single field, created, when I listed it with javap. So with this model you cannot read image-token usage from the Spring AI response; if you bill your own users by tokens you need to count by size and quality yourself, or read the SDK response.
Moderation: what Spring AI gives you, and what it does not
This is the section where it is easy to believe the library does more than it does, so here are the facts, each checked. Spring AI has aModerationModel interface and an OpenAiModerationModel implementation (default model omni-moderation-latest). You give it a ModerationPrompt and it returns a list of results with a flagged boolean and category flags. That is useful for the text of a prompt. What it is not:
- It is not connected to
ImageModel.ImageModelhas a singlecallmethod; no moderation happens inside it. If you do not callModerationModel, nothing does it for you at the library level. - It is text only.
ModerationMessagehas one getter,getText(printed in01-api-shape.txt). OpenAI’s moderation guide saysomni-moderation-latestaccepts images (files up to 20 MB) and that the endpoint is free to use. Spring AI’s wrapper has nowhere to put the image. Checking a generated picture means calling the OpenAI SDK or an HTTP client yourself. I did not build or run that. - The provider’s own filter is not exposed as an option. OpenAI’s guide says all prompts and generated images are filtered, and that GPT Image models have a
moderationrequest parameter (autoorlow).OpenAiImageOptionsin 2.0.2 has no such getter or builder method; the test printsfalsefor it. You get the default strictness. - Google’s equivalents are exposed.
GoogleGenAiImageOptionshassafetyFilterLevelandpersonGeneration, with the enum values printed in the same file. Google’s image documentation also says every generated image carries a SynthID watermark; that is the provider’s doing, and I did not inspect an image to confirm it. - A provider refusal arrives as an exception, not a flag. Below.
OpenAiImageModel (file: 04-wire-refusal.txt). My mock returns the content_policy_violation error shape; OpenAI’s current guide describes a newer one, with error.code equal to moderation_blocked and an optional moderation_details object, and says not to retry such errors without changing the prompt. I did not reproduce the newer shape, so the exception type below is what the SDK threw for a 400 in general:
exception class : com.openai.errors.BadRequestException
message (first 200): 400: Your request was rejected as a result of our safety system.
requests received : 1 (maxRetries=0)
Because the exception is the SDK’s own type (com.openai.errors.BadRequestException), catching it ties your service to the OpenAI SDK. In a service that might switch providers, catch it in one place and translate it into your own reason code. The SDK also retries by default (AbstractOpenAiOptions.DEFAULT_MAX_RETRIES is 3, printed in the API-shape file), so check what your retry setting does to a refusal; the test sets maxRetries(0) so that exactly one request is visible.
What our code does about the text is the PromptGate (PromptGate.java). It runs cheap checks first, then asks the moderation model, and, importantly, refuses the prompt if the moderation call itself fails:
public void check(String prompt) {
if (prompt == null || prompt.isBlank()) {
throw new RejectedException("empty-prompt", "prompt is blank");
}
if (prompt.length() > policy.maxPromptChars()) {
throw new RejectedException("prompt-too-long", prompt.length() + " > " + policy.maxPromptChars());
}
if (prompt.chars().anyMatch(c -> Character.isISOControl(c) && c != '\n' && c != '\t')) {
throw new RejectedException("control-characters", "prompt contains control characters");
}
List<String> flagged = new ArrayList<>();
try {
for (ModerationResult r : moderation.call(new ModerationPrompt(prompt)).getResult().getOutput().getResults()) {
if (r.isFlagged()) {
flagged.addAll(flaggedCategories(r.getCategories()));
}
}
} catch (RuntimeException e) {
throw new RejectedException("moderation-unavailable", e.getClass().getSimpleName());
}
if (!flagged.isEmpty()) {
throw new RejectedException("prompt-flagged", String.join(",", flagged));
}
}
In the tests the moderation model is a fake that flags the word “blood” as violence. That proves the control flow: a flagged prompt reaches no image model, and a moderation outage is a refusal, not a pass (these lines are from 13-refusals.txt):
flagged by moderation -> prompt-flagged: violence
blank prompt -> empty-prompt: prompt is blank
moderation endpoint down -> moderation-unavailable: IllegalStateException
ImageModel calls : 0
objects stored : 0
Failing closed is a product decision. If the moderation endpoint is down and you refuse every prompt, your image feature is down too. If you let prompts through when moderation is down, your policy is only as available as a third-party endpoint. I chose to fail closed because the cost of a wrong image is higher than the cost of an error page; yours may differ. The fake here says nothing about how good a real moderation model is at your categories. Measure that with your own examples.
Going deeper: what is left uncovered
- The output image is not checked for content. Our verifier checks that the bytes are an image, not what is in the image. The provider’s generation-time filter is the only content check in this module.
- Prompt text you did not write. If a user can influence a prompt through a template, check the final string, not the user’s fragment. The same defence thinking is in Prompt Injection Defense in Spring AI.
- Child safety. OpenAI’s moderation guide says not to send known or suspected CSAM to the moderation endpoint, and that it is not designed for CSAM detection. That is a legal and process question for you, not a library feature.
- OpenAI: Moderation guide
Safe storage: the bytes are untrusted input
Oncecall returns, you hold bytes you did not produce. They came from a provider you trust to be well-behaved, over a network, possibly via a URL you did not choose. Treat them the way you would treat an upload from a user, because the failure modes are the same: a file that is not what it claims, a file so large it hurts, a name that points somewhere it should not, and a browser that is willing to run something you stored.
The service puts four steps between the model and the bucket. For each, there is a test that shows what it refuses. First, the byte checks (file: 06-verifier.txt):
input verdict detail
real PNG 64x48 ACCEPT image/png 64x48
real JPEG 64x48 ACCEPT image/jpeg 64x48
WebP header only (sniffed, not decoded) ACCEPT image/webp
SVG with a script REJECT not-an-image: no known image signature in the first bytes
HTML page REJECT not-an-image: no known image signature in the first bytes
PDF REJECT not-an-image: no known image signature in the first bytes
PNG signature + garbage REJECT undecodable: IIOException
valid PNG + 40 bytes of HTML appended ACCEPT image/png 16x16
PNG header claiming 40000x40000 REJECT too-many-pixels: 40000x40000 = 1600000000 > cap 16000000
GIF (sniffed but not on the allow list) REJECT type-not-allowed: image/gif
empty REJECT not-an-image: no known image signature in the first bytes
9 MB of PNG-looking zeros REJECT too-large: 9437184 bytes > cap 8388608
Read the verdict column against the input column. Four ideas are at work. Size is checked first, on the byte count, before anything is parsed. The type is decided from the first bytes, not from a file name or a MIME type the sender gave: the HTML, SVG and PDF all fail with “no known image signature”. The pixel count is read from the header and compared with a cap before any decoder allocates a bitmap: the PNG that claims 40000 by 40000 would need gigabytes if decoded, and is refused from the header alone. A real decode catches the file that has a PNG signature and nothing behind it. WebP is only sniffed, because the JDK ships no WebP reader.
Here is the verifier (source: ImageVerifier.java):
public Sniffed verify(byte[] bytes) {
if (bytes.length > policy.maxBytes()) {
throw new RejectedException("too-large", bytes.length + " bytes > cap " + policy.maxBytes());
}
Sniffed s = ImageSniffer.sniff(bytes)
.orElseThrow(() -> new RejectedException("not-an-image", "no known image signature in the first bytes"));
if (!policy.allowedTypes().contains(s.contentType())) {
throw new RejectedException("type-not-allowed", s.contentType());
}
if (s.pixels() > policy.maxPixels()) {
throw new RejectedException("too-many-pixels", s.width() + "x" + s.height() + " = " + s.pixels() + " > cap " + policy.maxPixels());
}
if (!s.contentType().equals("image/webp")) {
try {
BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes));
if (img == null) {
throw new RejectedException("undecodable", "signature says " + s.contentType() + " but no decoder accepts it");
}
} catch (IOException | RuntimeException e) {
if (e instanceof RejectedException r) throw r;
throw new RejectedException("undecodable", e.getClass().getSimpleName());
}
}
return s;
}
A signature check plus a decode is not a guarantee, and I wanted the limit on record rather than implied. A valid PNG with a script appended after the image data is accepted (file: 07-trailing-bytes.txt):
input : valid 16x16 PNG followed by 25 bytes of <script>
ImageVerifier : ACCEPT (image/png)
why it is still OK: the object is stored as Content-Type image/png with
Content-Disposition: attachment, so a browser will not run it as HTML.
The check is the header, not the sniffing.
What makes that safe is the next step, not the sniffing: the object is written with the Content-Type we derived and Content-Disposition: attachment, so a browser downloads it instead of rendering it. The following block shows what happens when a writer trusts the sender instead. The first line is a real MinIO object that was uploaded with whatever type the sender asked for; the rest is our verifier refusing the same bytes (file: 11-naive-vs-pinned.txt):
naive writer : key naive/upload.png Content-Type = text/html (sender said so; the .png suffix did not matter)
ImageStore : the verifier refuses these bytes first:
not-an-image: no known image signature in the first bytes
objects under naive/ : 1
The storage code (source: ImageStore.java) never lets the caller influence the key. The diagram shows where each part of the key comes from, and which inputs are deliberately not used:
public StoredImage put(String owner, byte[] bytes, Sniffed sniffed, String revisedPrompt) {
requireOwner(owner);
byte[] raw = new byte[16];
RANDOM.nextBytes(raw);
String id = HexFormat.of().formatHex(raw);
String key = keyFor(owner, id, sniffed.extension());
String sha = sha256(bytes);
s3.putObject(PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(sniffed.contentType()) // from the bytes, never from the sender
.contentDisposition("attachment") // browsers download instead of rendering
.cacheControl("private, max-age=3600")
.metadata(Map.of("sha256", sha, "owner", owner))
.build(), RequestBody.fromBytes(bytes));
return new StoredImage(key, id, sniffed.contentType(), bytes.length, sniffed.width(), sniffed.height(), sha, revisedPrompt);
}
public String keyFor(String owner, String id, String extension) {
requireOwner(owner);
if (!ID.matcher(id).matches()) {
throw new RejectedException("bad-id", "an image id is 32 lowercase hex characters");
}
return "generated/" + owner + "/" + id + "." + extension;
}
The key test feeds the store hostile owners and ids (file: 10-keys.txt). The first line is the point of using random ids: 200 uploads of identical bytes made 200 objects, which a content hash would not have done, and a content hash would also have told one user that another user had already uploaded the same picture.
200 puts of identical bytes -> 200 distinct ids (a content hash would give 1)
owner '../admin' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
owner 'user-42/../../x' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
owner 'USER' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
owner '' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
owner 'a b' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
owner 'user-42\0' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
owner 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' -> bad-owner: owner must match [a-z0-9][a-z0-9-]{0,31}
id '../../etc/passwd' -> bad-id: an image id is 32 lowercase hex characters
id 'ABCDEF0123456789ABCDEF0123456789' -> bad-id: an image id is 32 lowercase hex characters
id '0123' -> bad-id: an image id is 32 lowercase hex characters
id '0123456789abcdef0123456789abcde/' -> bad-id: an image id is 32 lowercase hex characters
user-43 asks for user-42's id -> NoSuchKeyException (key is rebuilt under user-43/)
The MinIO tests (StoreMinioTest.java) show how the object looks to the server. The image was a real MinIO release, and the HEAD response shows the headers we set (file: 05-store-minio.txt):
image : chainguard/minio:latest
minio --version : minio version RELEASE.2026-10-23T20-06-51Z (commit-id=76c06ff1d2cc844050fdeb1d54bf8fa70db72278)
Server header : MinIO
stored key : generated/user-42/4cf346e4b7008b1874935d7969e8ccb1.png
content type : image/png, 6363 bytes, 64x48
sha256 : 7254899daa04ebf862c59d32d2d3280bb179e6c61899505c43aed169c6197bcb
HEAD Content-Type : image/png
HEAD Content-Disposition : attachment
HEAD Cache-Control : private, max-age=3600
HEAD metadata : {owner=user-42, sha256=7254899daa04ebf862c59d32d2d3280bb179e6c61899505c43aed169c6197bcb}
read back equal : true
And the presigned read. The URL is good for five minutes. Changing the owner in the path, or removing the signature, gets a 403 (file: 05b-presigned.txt):
signed URL, parameters : X-Amz-Algorithm=... & X-Amz-Date=... & X-Amz-SignedHeaders=host & X-Amz-Credential=... & X-Amz-Expires=300 & X-Amz-Signature=...
GET signed URL : 200, 742 bytes, Content-Type image/jpeg
Content-Disposition : attachment
same URL, other owner : 403
same path, no signature : 403
Presigned URLs are bearer tokens. Anyone who holds the URL can read the object until it expires, and it ends up in browser history and proxy logs. Keep the lifetime short, and prefer generating one per request after you have checked that the logged-in user owns the id, rather than storing URLs. The ownership check here is structural (the owner is part of the rebuilt key) but the application still has to pass the session’s owner, not one from the request.
Going deeper: an oversized base64 string, and a URL that points somewhere else
Two of the checks run before the byte checks. If the provider returned base64, the service estimates the decoded size from the string length first and refuses if it passes the cap, so a hostile or broken 2 GB string never becomes a 1.5 GB array (file: 08-base64-cap.txt):
payload chars : 12582912 (decodes to about 9437184 bytes; cap is 8388608)
result : too-large: base64 payload decodes to about 9437184 bytes > cap 8388608If the provider returned a URL (Image has getUrl for that case, and the wire test above showed the OpenAI model passing one through), the service has to download it, and now the provider’s response decides which address your server connects to. RemoteFetcher (RemoteFetcher.java) allows https only, only listed hosts, does not follow redirects, has timeouts, and stops reading at the byte cap. The cases, against a local image host (file: 09-fetcher.txt):
case result
allowed host, 200 OK 7875 bytes
302 redirect to the same host fetch-status: 302
endless body (cap 1 MiB) too-large: download passed the 1048576-byte cap
host not on the list host-not-allowed: localhost
file:// URL bad-scheme: file
http:// when insecure is off bad-scheme: http
cloud metadata address host-not-allowed: 169.254.169.254The test host is plain http://127.0.0.1, which is why the policy for that test sets allowInsecure; production policy does not. The last line matters most: a URL pointing at the cloud metadata address is refused because its host is not on the list. A host list defends against a URL in a response; it does not defend against a listed host that later resolves to a private address (DNS rebinding), and I did not test that. If you must fetch from a name you do not control, resolve it yourself and check the address.
private byte[] readCapped(InputStream in) throws IOException {
try (in) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
long total = 0;
int n;
while ((n = in.read(buf)) > 0) {
total += n;
if (total > policy.maxBytes()) {
throw new RejectedException("too-large", "download passed the " + policy.maxBytes() + "-byte cap");
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
- The
image/webpbranch is sniffed only. If you accept WebP and want it decoded, add a WebP reader (a native library or a plug-in) and test it; I did not. - GIF is recognised and rejected by policy, not by accident (the
type-not-allowedline in the verifier output). Add it toallowedTypesdeliberately if you want it.
Going deeper: bucket settings this module does not make for you
The tests create a bucket with defaults. A production bucket should block public access, enforce TLS, and have a lifecycle rule that expires objects you do not need. None of that is in this module, because I ran MinIO as a throwaway container, and what I can claim is limited to what the tests exercised: the object headers shown above and the presigned URL behaviour. Public access settings and lifecycle rules are provider-specific (AWS, MinIO and others differ) and are worth a separate review.
Putting it together: one service, four ways to fail early
The service is short because every decision is delegated to a class that has its own test. It runs the prompt gate, builds options, calls the model, verifies every result, and only then stores anything (source:ImageService.java):
public List<StoredImage> generate(ImageRequest request) {
gate.check(request.prompt());
ImageOptions o = options.apply(request);
ImageResponse response = model.call(new ImagePrompt(request.prompt(), o));
record Pending(byte[] bytes, Sniffed sniffed, String revised) {}
List<Pending> verified = new ArrayList<>();
for (ImageGeneration g : response.getResults()) {
byte[] bytes = bytesOf(g.getOutput());
Sniffed sniffed = verifier.verify(bytes);
String revised = g.getMetadata() instanceof OpenAiImageGenerationMetadata m ? m.getRevisedPrompt() : null;
verified.add(new Pending(bytes, sniffed, revised));
}
List<StoredImage> stored = new ArrayList<>();
for (Pending p : verified) {
stored.add(store.put(request.owner(), p.bytes(), p.sniffed(), p.revised()));
}
return stored;
}
The double loop is deliberate: all results are verified before the first one is stored. A three-image response whose second image is HTML stores nothing, instead of leaving a half-finished batch in the bucket (file: 14-batch.txt):
result : not-an-image: no known image signature in the first bytes
objects under batch/ : 0
And the end-to-end path with the scripted model, scripted moderation and the real MinIO bucket (file: 12-service-happy.txt). The options line shows what the model actually received, which is the check that the model name and size reached it:
moderation saw : [a red bicycle]
ImageModel calls : 1
options sent : OpenAiImageOptions model=gpt-image-test size=1536x1024 quality=high n=1
prompt sent : a red bicycle
stored : generated/happy/65ed822c50b77fecbcedfd866b567c54.png image/png 7875 bytes 64x64
revised prompt : a red bicycle on a quiet street, photographic
objects in bucket under generated/happy/ : 1
Going deeper: provider results that are URLs, and what the service does with them
The same service handles a URL result by routing it through the fetcher and then through exactly the same verifier. A listed host is fetched and stored; a metadata-service URL and a garbage base64 string are refused (file: 15-url-results.txt):
allowed host : stored generated/urls/f009e9267986ffc76e6b2aacec391b12.png (image/png, 7875 bytes)
metadata-service URL : host-not-allowed: 169.254.169.254
garbage base64 : bad-base64: payload is not valid base64There is no HTTP controller in this module on purpose. The edge of your service decides how a RejectedException becomes a status code and a user message, and that depends on your API. The one rule that does not change: the owner in the ImageRequest comes from the authenticated session, never from the request body.
What it costs, by provider
Image generation is billed per image or per token depending on the provider, and the prices moved while I was preparing this article, so each number below carries the page it came from. I read the OpenAI and Google pages and the Stability AI page on 2026-10-28. Google’s page said “Last updated 2026-10-27 UTC”. The values are inCostModel.java with the same date, and the test only does arithmetic on them (file: 16-cost.txt).
OpenAI, output only, text prompt tokens extra
model quality 1024x1024 1024x1536 1536x1024
gpt-image-2 low $0.006 $0.005 $0.005
gpt-image-2 medium $0.053 $0.041 $0.041
gpt-image-2 high $0.211 $0.165 $0.165
gpt-image-1.5 low $0.009 $0.013 $0.013
gpt-image-1.5 medium $0.034 $0.05 $0.05
gpt-image-1.5 high $0.133 $0.2 $0.2
gpt-image-1-mini low $0.005 $0.006 $0.006
gpt-image-1-mini medium $0.011 $0.015 $0.015
gpt-image-1-mini high $0.036 $0.052 $0.052
Google Gemini image models, standard tier
gemini-3.1-flash-lite-image 1K $0.0336
gemini-3.1-flash-image 0.5K $0.045
gemini-3.1-flash-image 1K $0.067
gemini-3.1-flash-image 2K $0.101
gemini-3.1-flash-image 4K $0.151
gemini-3-pro-image 1K-2K $0.134
gemini-3-pro-image 4K $0.24
gemini-2.5-flash-image 1K $0.039
Stability AI, 1 credit = $0.01
Stable Image Ultra 8.0 credits = $0.080
Stable Diffusion 3.5 Large 6.5 credits = $0.065
Stable Diffusion 3.5 Large Turbo 4.0 credits = $0.040
Stable Diffusion 3.5 Medium 3.5 credits = $0.035
Stable Image Core 3.0 credits = $0.030
Stable Diffusion 3.5 Flash 2.5 credits = $0.025
Put side by side, with the page each came from:
| Provider and model (as the page names it) | What is priced | USD per image | Source page, read 2026-10-28 |
|---|---|---|---|
OpenAI gpt-image-2 | output only, 1024×1024: low / medium / high | 0.006 / 0.053 / 0.211 | OpenAI image generation guide, “Calculating costs” |
OpenAI gpt-image-2 | output only, 1024×1536 and 1536×1024: low / medium / high | 0.005 / 0.041 / 0.165 | same table |
OpenAI gpt-image-1.5 | 1024×1024: low / medium / high | 0.009 / 0.034 / 0.133 | same table |
OpenAI gpt-image-1-mini (Spring AI’s default) | 1024×1024: low / medium / high | 0.005 / 0.011 / 0.036 | same table |
Google gemini-3.1-flash-lite-image | 1K, standard tier | 0.0336 | Gemini API pricing page |
Google gemini-3.1-flash-image | 0.5K / 1K / 2K / 4K, standard | 0.045 / 0.067 / 0.101 / 0.151 | same page |
Google gemini-3-pro-image | 1K-2K / 4K, standard | 0.134 / 0.24 | same page |
Google gemini-2.5-flash-image | up to 1024×1024, standard | 0.039 | same page |
| Stability AI Stable Image Core / SD 3.5 Large / Ultra | 3 / 6.5 / 8 credits per call at $0.01 per credit | 0.03 / 0.065 / 0.08 | Stability AI pricing page |
What is deliberately missing from the table. (1) OpenAI’s two newest models,To turn the table into a budget, multiply. At 1,000 images a day for 30 days, which is 30,000 images, the test computes (file:gpt-image-2.5-sunburstandgpt-image-2.5-flare, are listed on the pricing page in tokens only ($30 per 1M image output tokens, $8 per 1M image input tokens, $5 per 1M text input tokens); the guide gives a calculator but no fixed per-image table, so I give no per-image figure. (2)gpt-image-1is not listed because the guide says it was shut down on 2026-10-23. (3) DALL-E is not in the pricing page’s image section on the date I read it, so I give no DALL-E price. (4) Stability: those credit prices are for the services the page lists (Stable Image Core, SD 3.5 and so on). Spring AI’sStabilityAiApicalls/v1/generation/{model}/text-to-imagewith a default ofstable-diffusion-v1-6; that engine is not on the pricing page, so I cannot tell you what a call through the Spring AI module costs. (5) OpenAI’s figures are for the image output only; the prompt’s text tokens are billed on top. (6) Batch pricing exists on both the OpenAI and Google pages at roughly half the standard token rates; I list standard prices only.
16-cost.txt):
1,000 images a day for 30 days = 30,000 images
gpt-image-2 medium 1024x1024 $1,590
gpt-image-1-mini medium 1024x1024 $330
gemini-3.1-flash-image 1K $2,010
Stable Image Core $900
The line to take from that is the spread, not any single number: the same traffic is a few hundred dollars or a couple of thousand depending on model and quality. The cheapest lever you control is the one the allow-list already gives you, because it stops a client asking for high when your product needs medium. A second lever is not charging for images you are about to throw away, which is why the moderation call and the options checks run before the paid call. OpenAI’s moderation endpoint is free to use according to its guide; a rejected prompt costs a moderation call and no image.
Going deeper: the cost model is only arithmetic
The CostModel class holds numbers and a date; it does not read any page. The test multiplies. Nothing keeps the numbers current, so a price change on a provider page will not fail a build. If cost matters to your product, put the figures in configuration, show the date next to them in your admin screen, and re-read the page when you change the model. Token-billed models also mean your own estimate and the invoice can differ: the Spring AI response on the OpenAI path carried no usage in my test (see above), so reconcile against the provider’s billing export, not against a counter in your code.
Generation or vision: which half do you need?
“Image” covers two different jobs, and Spring AI draws the line in the API. Generation turns text into a picture with anImageModel. Vision turns a picture into text or data: you attach the image to a chat message as Media and talk to a ChatModel through ChatClient. That is the subject of the multimodal article, which extracts a typed receipt from a photo and checks its arithmetic.
| You want to… | Use | Why |
|---|---|---|
| Make a new picture from a description | ImageModel (this article) | A chat model returns text. An image model returns bytes. |
| Read text or fields from a photo or scan | Chat model with Media and entity(...) (multimodal article) | You get typed data back, and the same arithmetic checks apply. |
| Describe an uploaded image, tag it, or answer a question about it | Chat model with Media | The output is text. Nothing is generated or stored by you. |
| Check whether a user’s uploaded or generated image is acceptable | A moderation endpoint that takes images, called through the provider SDK | Spring AI’s ModerationModel is text only (see above). |
| Change an existing image | The provider’s edit endpoint, through its SDK | OpenAiImageOptions.toOpenAiImageGenerateParams returns ImageGenerateParams, the generation request; I found no edit path in the class. |
| Show the user a diagram or chart of data | Neither: draw it from the data | A generated chart can have the wrong numbers and look right. |
Should you even generate images on demand?
Often not on the request path. A generation call takes seconds on a good day and fails in ways your user cannot fix, so treat it as a background job with a status, not a blocking HTTP call. Count the refusals, because a rate of refused prompts is the first signal that your form invites something you did not intend. Keep the originals you store out of any public path. And if your product needs a few hundred fixed pictures, generate them once, review them by hand and ship them as static files: that is cheaper, safer and faster than any of the code above.
What this module does not show. It never ran against a real image model, so image quality, latency, real refusal behaviour and real invoices are all outside what I can claim. The bucket was a throwaway MinIO server, not S3. The checks stop what I tested them against; a determined attacker will test them against more.
Further reading
- The code: image-gen in asmhatre/spring-ai, with output/ holding every transcript quoted above, regenerated by scripts/run-all.sh (about 20 seconds, needs Docker).
- Multimodal Spring AI: Extract Structured Data from Images, the vision half.
- Anthropic Claude vs OpenAI vs Gemini in Spring AI 2.0, for the portable-versus-provider-specific options theme and the same options-reset trap in chat.
- Observability for Spring AI, for turning usage into cost. Image models have their own observation classes in
org.springframework.ai.image.observation; I did not run them. - Prompt Injection Defense in Spring AI.
- Spring AI 1.x to 2.0 migration guide.
- OpenAI: Image generation guide
- OpenAI: Moderation guide
- Google: Gemini image generation
- Stability AI: pricing
- Spring AI reference: Image Model API
No Comments yet!