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
+45
View File
@@ -0,0 +1,45 @@
# image-gen
Companion code for [Image Generation and Vision in Spring AI: ImageModel, Prompt Options and Safe Storage](https://ankurm.com/spring-ai-image-generation-imagemodel-s3-minio-safe-storage/), part of the [Spring AI series](../README.md) on ankurm.com. It is the generation half of [Multimodal Spring AI: Extract Structured Data from Images](https://ankurm.com/multimodal-spring-ai-extract-structured-data-from-images-receipts-java-records/).
A prompt goes through our own checks, into `ImageModel`, and the bytes that come back are verified and written to an S3-compatible bucket under a key we choose.
**No image provider was called.** The tests use two hand-written fakes -- `ScriptedImageModel` (an `ImageModel` that returns what it was told to) and `ScriptedModerationModel` -- and, for the wire tests, the real `OpenAiImageModel` against a local mock of `/v1/images/generations` (`FakeImagesServer`) that returns a fixed 64x64 PNG whatever the prompt says. Nothing here measures image quality, latency, or whether a real model would refuse a prompt. **The storage is real:** the `ImageStore` tests run against a MinIO server in Docker through Testcontainers (the image is `chainguard/minio`, an upstream MinIO build, because `minio/minio` and `quay.io/minio/minio` could not be pulled from the machine these were captured on; the exact version is in `output/05-store-minio.txt`). Prices in `CostModel` were copied from the providers' pages on 2026-10-28 and are not checked by any test.
| | |
|---|---|
| Spring Boot | 4.1.2 (parent; GA on Maven Central 2026-10-22) |
| Spring AI | 2.0.2 (`spring-ai-model`, `spring-ai-openai`, `spring-ai-stability-ai`, `spring-ai-google-genai-image`) |
| AWS SDK for Java | 2.57.5 (`s3`) |
| Testcontainers | 2.0.5 |
| Java | 25 |
| Run | `scripts/run-all.sh` (about 20 s, needs Docker for the MinIO tests) |
## Source
| File | What it is |
|---|---|
| [`ImageService`](src/main/java/com/ankurm/imagegen/ImageService.java) | prompt gate -> `ImageModel` -> bytes -> verify -> store; one bad image in a batch stores nothing |
| [`PromptGate`](src/main/java/com/ankurm/imagegen/PromptGate.java) | length and control-character checks, then `ModerationModel`; fails closed when moderation is down |
| [`OpenAiOptionsFactory`](src/main/java/com/ankurm/imagegen/OpenAiOptionsFactory.java) | size / quality / n checked against allow-lists, then `OpenAiImageOptions` with the model set explicitly |
| [`ImageSniffer`](src/main/java/com/ankurm/imagegen/ImageSniffer.java), [`ImageVerifier`](src/main/java/com/ankurm/imagegen/ImageVerifier.java), [`ImagePolicy`](src/main/java/com/ankurm/imagegen/ImagePolicy.java) | magic-number sniffing, size cap, pixel cap read from the header, a real decode, allow-list of types |
| [`RemoteFetcher`](src/main/java/com/ankurm/imagegen/RemoteFetcher.java) | downloads a provider-returned URL: https and listed hosts only, no redirects, hard byte cap |
| [`ImageStore`](src/main/java/com/ankurm/imagegen/ImageStore.java) | S3/MinIO writer: server-chosen key, pinned `Content-Type`, `Content-Disposition: attachment`, presigned GET |
| [`CostModel`](src/main/java/com/ankurm/imagegen/CostModel.java) | per-image prices with the date they were read and the page they came from |
## Captured output (`output/`)
| File | Shows |
|---|---|
| `01-api-shape.txt` | class names, portable vs provider options, what is *not* there (moderation option, image moderation) |
| `02-wire-requests.txt` | JSON body sent by the real `OpenAiImageModel` for six option shapes, including the model reset and the `ImagePrompt(List)` failure |
| `03-wire-response.txt` | what `ImageResponse` carries back (b64 or URL, revised prompt, no usage) |
| `04-wire-refusal.txt` | a provider 400 `content_policy_violation` arrives as an SDK exception |
| `05-store-minio.txt`, `05b-presigned.txt` | the object as MinIO holds it; presigned URL behaviour |
| `06-verifier.txt`, `07-trailing-bytes.txt`, `08-base64-cap.txt` | twelve inputs against the verifier; the limit of sniffing; base64 refused before decoding |
| `09-fetcher.txt` | redirect, scheme, host list, endless body |
| `10-keys.txt`, `11-naive-vs-pinned.txt` | owner and id validation; trusting the sender's `Content-Type` |
| `12-service-happy.txt` .. `15-url-results.txt` | the service end to end; refusals before any paid call; a batch with one bad image; URL results |
| `16-cost.txt` | the per-image price table |
Keys, ports and timestamps differ between runs; everything else is stable.