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:
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user