Add image-gen module: Spring AI ImageModel options on the wire, a moderation gate, and safe storage of generated images in MinIO (sniffed content type, byte and pixel caps, server-chosen keys, capped URL fetcher); per-provider cost table dated 2026-10-28. ImageModel and ModerationModel are hand-written fakes; MinIO is real via Testcontainers; no image provider called.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
2026-10-28 18:36:40 +00:00
co-authored by Claude Sonnet 5.5
parent 108fc44542
commit aa8b1604ed
48 changed files with 2112 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Spring AI 2.0.2 image classes (read from the jars)
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
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]
OpenAiImageOptions.DEFAULT_IMAGE_MODEL = gpt-image-1-mini
OpenAiModerationOptions.DEFAULT_MODERATION_MODEL = omni-moderation-latest
OpenAiImageOptions has a getModeration() : false
ModerationMessage getters : [getText] (text only, no media)
Stability default engine / base URL : stable-diffusion-v1-6 @ https://api.stability.ai/v1
GoogleGenAiImageModelName : [gemini-2.5-flash-image, gemini-3-pro-image, gemini-3.1-flash-image]
Google SafetyFilterLevel : [BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH, BLOCK_NONE, SAFETY_FILTER_LEVEL_UNSPECIFIED]
Google PersonGeneration : [DONT_ALLOW, ALLOW_ADULT, ALLOW_ALL, PERSON_GENERATION_UNSPECIFIED]
AbstractOpenAiOptions.DEFAULT_MAX_RETRIES (image options extend it) = 3
portable builder gives : DefaultImageModelOptions
+21
View File
@@ -0,0 +1,21 @@
# What OpenAiImageModel sends (mock server; bodies recorded as received)
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"}
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"}
+12
View File
@@ -0,0 +1,12 @@
# What comes back from OpenAiImageModel (mock server)
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
+5
View File
@@ -0,0 +1,5 @@
# A provider-side refusal (400 content_policy_violation from the mock)
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)
+13
View File
@@ -0,0 +1,13 @@
# ImageStore on a real MinIO server
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
+7
View File
@@ -0,0 +1,7 @@
# Presigned GET (5 minutes) against MinIO
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
+15
View File
@@ -0,0 +1,15 @@
# ImageVerifier decisions (cap 8 MiB, 16,000,000 pixels, png/jpeg/webp)
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
+7
View File
@@ -0,0 +1,7 @@
# Sniff + decode does not catch bytes after the image
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.
+4
View File
@@ -0,0 +1,4 @@
# Oversized base64 is refused from its length, before decoding
payload chars : 12582912 (decodes to about 9437184 bytes; cap is 8388608)
result : too-large: base64 payload decodes to about 9437184 bytes > cap 8388608
+10
View File
@@ -0,0 +1,10 @@
# RemoteFetcher against a local image host
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
+15
View File
@@ -0,0 +1,15 @@
# Key construction
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/)
+6
View File
@@ -0,0 +1,6 @@
# Trusting the sender's Content-Type vs pinning it
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
+9
View File
@@ -0,0 +1,9 @@
# ImageService: scripted ImageModel, scripted moderation, real MinIO
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
+11
View File
@@ -0,0 +1,11 @@
# What is refused before any image is paid for
flagged by moderation -> prompt-flagged: violence
blank prompt -> empty-prompt: prompt is blank
prompt over 4000 chars -> prompt-too-long: 4001 > 4000
control character -> control-characters: prompt contains control characters
size not in list -> bad-size: 9999x9999 not in [1024x1024, 1024x1536, 1536x1024]
quality not in list -> bad-quality: ultra not in [high, low, medium]
moderation endpoint down -> moderation-unavailable: IllegalStateException
ImageModel calls : 0
objects stored : 0
+4
View File
@@ -0,0 +1,4 @@
# A three-image response where the second is HTML
result : not-an-image: no known image signature in the first bytes
objects under batch/ : 0
+5
View File
@@ -0,0 +1,5 @@
# Provider returns a URL instead of bytes
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
+38
View File
@@ -0,0 +1,38 @@
# Per-image prices as read from provider pages on 2026-10-28 (USD)
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
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
Retrying every refusal once doubles the bill for those requests; the moderation call is separate.