Skip to main content

Image Generation and Vision in Spring AI: ImageModel, Prompt Options and Safe Storage

How to generate images with Spring AI’s ImageModel and keep them safely: what the real OpenAiImageModel sends (including an options object that silently resets the model), a moderation gate and what Spring AI does not moderate, verified storage in MinIO/S3 with sniffed content types, size and pixel caps and server-chosen keys, and a per-provider cost table dated 2026-10-28. No image provider was called; the model and moderation are scripted fakes and the bucket is a real MinIO.

You have a form that says “describe the picture you want”, and a model that will draw it. Calling the model is a few lines. The rest of the job is what the few lines leave out: which sizes and quality settings you are allowed to ask for, what you are about to be charged, whether the prompt should have been allowed in at all, and, the part that is easy to get wrong, what you do with a file that a third party produced and you are about to put in a bucket other people will read from. This article is about the generation half of working with images in Spring AI. The other half, handing an image to a model and getting data back, is in Multimodal Spring AI: Extract Structured Data from Images; here you will see where each one is the right tool. The depth is in expandable sections, so you can read straight through or open only what you need.
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 the image-gen module of asmhatre/spring-ai, and every console block below is quoted from a file under its output/ directory. I did not call any image provider. There are no API keys in my build environment, so the tests use a hand-written ImageModel that returns what it is told to, a hand-written ModerationModel, and, for the wire tests, the real OpenAiImageModel talking 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 is ImageModel 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.
Before the paid callRequestowner from sessionprompt, size, qualityPromptGatelength, control charsModerationModel callImageModelcall(ImagePrompt)returns ImageResponseProviderits own filters,its own errorsAfter the paid callImage bytesImage.getB64Json()or Image.getUrl()RemoteFetcherURLs only: https,listed hosts, byte capImageVerifierbyte and pixel caps,sniff, decode, typesImageStoreserver-chosen key,pinned Content-TypeBlue is Spring AI, green is code you write, dashed amber is the provider. One box is the library.Everything that decides whether a prompt is allowed in, or a file is safe to keep, is green.
Here are the class names, read from the 2.0.2 jars by a test rather than from the documentation (full file: 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.

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. Because ImageModel 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 real OpenAiImageModel 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 an OpenAiImageOptions passed to ImagePrompt does not inherit the model configured on the OpenAiImageModel; it replaces it with OpenAiImageOptions.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.
Two smaller traps come from the sixth case (file: 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 user parameter is deprecated in favour of safety_identifier. OpenAiImageOptions in 2.0.2 has getUser and no safety-identifier getter (see the listing above), so user is 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() = null

Three 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 a ModerationModel 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. ImageModel has a single call method; no moderation happens inside it. If you do not call ModerationModel, nothing does it for you at the library level.
  • It is text only. ModerationMessage has one getter, getText (printed in 01-api-shape.txt). OpenAI’s moderation guide says omni-moderation-latest accepts 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 moderation request parameter (auto or low). OpenAiImageOptions in 2.0.2 has no such getter or builder method; the test prints false for it. You get the default strictness.
  • Google’s equivalents are exposed. GoogleGenAiImageOptions has safetyFilterLevel and personGeneration, 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.
This is what a refusal looks like through the real 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

Once call 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:
generated/fixed prefixuser-42owner: session, regex[a-z0-9][a-z0-9-]{0,31}5fc331ab…e5id: SecureRandom,16 bytes, 32 hex.pngextension: fromthe sniffed bytesInputs that are never part of the keyfile name from the userMIME type the sender claimsprovider URL suffixA caller who wants an image back sends the 32-hex id. We rebuild the key from their own owner, so ../ has nowhere to go.
    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 8388608

If 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.254

The 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/webp branch 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-allowed line in the verifier output). Add it to allowedTypes deliberately 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 base64

There 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 in CostModel.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 pricedUSD per imageSource page, read 2026-10-28
OpenAI gpt-image-2output only, 1024×1024: low / medium / high0.006 / 0.053 / 0.211OpenAI image generation guide, “Calculating costs”
OpenAI gpt-image-2output only, 1024×1536 and 1536×1024: low / medium / high0.005 / 0.041 / 0.165same table
OpenAI gpt-image-1.51024×1024: low / medium / high0.009 / 0.034 / 0.133same table
OpenAI gpt-image-1-mini (Spring AI’s default)1024×1024: low / medium / high0.005 / 0.011 / 0.036same table
Google gemini-3.1-flash-lite-image1K, standard tier0.0336Gemini API pricing page
Google gemini-3.1-flash-image0.5K / 1K / 2K / 4K, standard0.045 / 0.067 / 0.101 / 0.151same page
Google gemini-3-pro-image1K-2K / 4K, standard0.134 / 0.24same page
Google gemini-2.5-flash-imageup to 1024×1024, standard0.039same page
Stability AI Stable Image Core / SD 3.5 Large / Ultra3 / 6.5 / 8 credits per call at $0.01 per credit0.03 / 0.065 / 0.08Stability AI pricing page
What is deliberately missing from the table. (1) OpenAI’s two newest models, gpt-image-2.5-sunburst and gpt-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-1 is 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’s StabilityAiApi calls /v1/generation/{model}/text-to-image with a default of stable-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.
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: 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 an ImageModel. 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…UseWhy
Make a new picture from a descriptionImageModel (this article)A chat model returns text. An image model returns bytes.
Read text or fields from a photo or scanChat 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 itChat model with MediaThe output is text. Nothing is generated or stored by you.
Check whether a user’s uploaded or generated image is acceptableA moderation endpoint that takes images, called through the provider SDKSpring AI’s ModerationModel is text only (see above).
Change an existing imageThe provider’s edit endpoint, through its SDKOpenAiImageOptions.toOpenAiImageGenerateParams returns ImageGenerateParams, the generation request; I found no edit path in the class.
Show the user a diagram or chart of dataNeither: draw it from the dataA generated chart can have the wrong numbers and look right.
The two halves do meet. A common loop is generate, then check what came back with a vision-capable chat model (“does this image contain readable text?”), then store. That second step uses a chat model, costs tokens, and is a second opinion rather than a guarantee; I did not build it here.

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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.