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
@@ -0,0 +1,71 @@
package com.ankurm.imagegen;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Per-image prices copied from each provider's own pricing pages, read on {@link #READ_ON}.
* Prices change; the date is the whole claim. A provider or size missing here is missing because
* the official page did not state a per-image figure we could read.
*
* <p>Sources:
* <ul>
* <li>OpenAI: https://developers.openai.com/api/docs/guides/image-generation ("Calculating costs" table)
* and https://developers.openai.com/api/docs/pricing (token rates)</li>
* <li>Google: https://ai.google.dev/gemini-api/docs/pricing (Gemini image models)</li>
* <li>Stability AI: https://docs.stability.ai/pricing (credits; 1 credit = $0.01)</li>
* </ul>
*/
public final class CostModel {
public static final String READ_ON = "2026-10-28";
public static final double STABILITY_USD_PER_CREDIT = 0.01;
private CostModel() {}
/** OpenAI per-image output prices, 1024x1024 / 1024x1536 / 1536x1024, as printed on the guide. Text prompt tokens are extra. */
public static Map<String, double[]> openAiPerImage() {
Map<String, double[]> m = new LinkedHashMap<>();
m.put("gpt-image-2 low", new double[] {0.006, 0.005, 0.005});
m.put("gpt-image-2 medium", new double[] {0.053, 0.041, 0.041});
m.put("gpt-image-2 high", new double[] {0.211, 0.165, 0.165});
m.put("gpt-image-1.5 low", new double[] {0.009, 0.013, 0.013});
m.put("gpt-image-1.5 medium", new double[] {0.034, 0.05, 0.05});
m.put("gpt-image-1.5 high", new double[] {0.133, 0.2, 0.2});
m.put("gpt-image-1-mini low", new double[] {0.005, 0.006, 0.006});
m.put("gpt-image-1-mini medium", new double[] {0.011, 0.015, 0.015});
m.put("gpt-image-1-mini high", new double[] {0.036, 0.052, 0.052});
return m;
}
/** Google: dollars per output image at the stated resolution, standard tier. */
public static Map<String, Double> googlePerImage() {
Map<String, Double> m = new LinkedHashMap<>();
m.put("gemini-3.1-flash-lite-image 1K", 0.0336);
m.put("gemini-3.1-flash-image 0.5K", 0.045);
m.put("gemini-3.1-flash-image 1K", 0.067);
m.put("gemini-3.1-flash-image 2K", 0.101);
m.put("gemini-3.1-flash-image 4K", 0.151);
m.put("gemini-3-pro-image 1K-2K", 0.134);
m.put("gemini-3-pro-image 4K", 0.24);
m.put("gemini-2.5-flash-image 1K", 0.039);
return m;
}
/** Stability: credits per call. */
public static Map<String, Double> stabilityCredits() {
Map<String, Double> m = new LinkedHashMap<>();
m.put("Stable Image Ultra", 8.0);
m.put("Stable Diffusion 3.5 Large", 6.5);
m.put("Stable Diffusion 3.5 Large Turbo", 4.0);
m.put("Stable Diffusion 3.5 Medium", 3.5);
m.put("Stable Image Core", 3.0);
m.put("Stable Diffusion 3.5 Flash", 2.5);
return m;
}
/** Dollars for {@code images} images with a flat per-image price. */
public static double total(double perImage, int images) {
return perImage * images;
}
}
@@ -0,0 +1,30 @@
package com.ankurm.imagegen;
import java.util.Set;
/**
* Every limit in one place so a test (and a reader) can see them.
*
* @param maxBytes largest image we will accept, decoded
* @param maxPixels largest width x height we will accept, read from the header before decoding
* @param maxPromptChars longest prompt we will send
* @param allowedTypes sniffed MIME types we will store
* @param fetchHosts hosts a provider-returned URL may point at (exact match, https only unless allowInsecure)
* @param allowInsecure allow http:// fetches; only for tests against a local server
*/
public record ImagePolicy(long maxBytes, long maxPixels, int maxPromptChars, Set<String> allowedTypes,
Set<String> fetchHosts, boolean allowInsecure) {
public static ImagePolicy defaults() {
return new ImagePolicy(8L * 1024 * 1024, 16_000_000L, 4000,
Set.of("image/png", "image/jpeg", "image/webp"), Set.of(), false);
}
public ImagePolicy withMaxBytes(long v) {
return new ImagePolicy(v, maxPixels, maxPromptChars, allowedTypes, fetchHosts, allowInsecure);
}
public ImagePolicy withFetch(Set<String> hosts, boolean insecure) {
return new ImagePolicy(maxBytes, maxPixels, maxPromptChars, allowedTypes, hosts, insecure);
}
}
@@ -0,0 +1,12 @@
package com.ankurm.imagegen;
/**
* What the HTTP layer hands the service. {@code owner} comes from the authenticated session, never
* from the request body. {@code size} and {@code quality} are validated against the provider's list.
*/
public record ImageRequest(String owner, String prompt, String size, String quality, int n) {
public ImageRequest(String owner, String prompt) {
this(owner, prompt, null, null, 1);
}
}
@@ -0,0 +1,83 @@
package com.ankurm.imagegen;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.function.Function;
import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageGeneration;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.openai.metadata.OpenAiImageGenerationMetadata;
/**
* prompt check -> ImageModel -> bytes -> verify -> store. Each arrow is a place something can be
* refused, and every refusal is a {@link RejectedException} with a stable reason code.
*
* <p>If one image of several fails verification nothing is stored: a half-saved batch is harder to
* reason about than a retry.
*/
public final class ImageService {
private final ImageModel model;
private final Function<ImageRequest, ImageOptions> options;
private final PromptGate gate;
private final ImagePolicy policy;
private final ImageVerifier verifier;
private final RemoteFetcher fetcher;
private final ImageStore store;
public ImageService(ImageModel model, Function<ImageRequest, ImageOptions> options, PromptGate gate,
ImagePolicy policy, RemoteFetcher fetcher, ImageStore store) {
this.model = model;
this.options = options;
this.gate = gate;
this.policy = policy;
this.verifier = new ImageVerifier(policy);
this.fetcher = fetcher;
this.store = store;
}
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;
}
byte[] bytesOf(Image image) {
if (image.getB64Json() != null) {
String b64 = image.getB64Json();
// Check the size from the string length BEFORE decoding, so a 2 GB string never becomes a 1.5 GB array.
long approx = (long) b64.length() * 3 / 4;
if (approx > policy.maxBytes() + 3) {
throw new RejectedException("too-large", "base64 payload decodes to about " + approx + " bytes > cap " + policy.maxBytes());
}
try {
return Base64.getDecoder().decode(b64);
} catch (IllegalArgumentException e) {
throw new RejectedException("bad-base64", "payload is not valid base64");
}
}
if (image.getUrl() != null) {
return fetcher.fetch(image.getUrl());
}
throw new RejectedException("empty-result", "provider returned neither b64_json nor url");
}
}
@@ -0,0 +1,80 @@
package com.ankurm.imagegen;
import java.util.Optional;
/**
* Decides what a byte array is from its first bytes, ignoring every label the sender supplied
* (file name, MIME type, URL suffix). Only four raster formats are recognised; SVG, HTML, PDF and
* anything else come back empty and are refused by the caller.
*
* <p>Sniffing the magic number is necessary, not sufficient: a file can start like a PNG and
* carry anything after it. That is why {@link ImageVerifier} also decodes it and why the store
* pins the Content-Type itself. See the article's "safe storage" section.
*/
public final class ImageSniffer {
private ImageSniffer() {}
public static Optional<Sniffed> sniff(byte[] b) {
if (b == null || b.length < 12) {
return Optional.empty();
}
if (u(b, 0) == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G'
&& u(b, 4) == 0x0D && u(b, 5) == 0x0A && u(b, 6) == 0x1A && u(b, 7) == 0x0A) {
// IHDR: length(4) "IHDR"(4) width(4) height(4)
int w = b.length >= 24 ? be32(b, 16) : 0;
int h = b.length >= 24 ? be32(b, 20) : 0;
return Optional.of(new Sniffed("image/png", "png", w, h));
}
if (u(b, 0) == 0xFF && u(b, 1) == 0xD8 && u(b, 2) == 0xFF) {
int[] wh = jpegSize(b);
return Optional.of(new Sniffed("image/jpeg", "jpg", wh[0], wh[1]));
}
if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F' && b[3] == '8' && (b[4] == '7' || b[4] == '9') && b[5] == 'a') {
int w = u(b, 6) | (u(b, 7) << 8);
int h = u(b, 8) | (u(b, 9) << 8);
return Optional.of(new Sniffed("image/gif", "gif", w, h));
}
if (b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P') {
return Optional.of(new Sniffed("image/webp", "webp", 0, 0));
}
return Optional.empty();
}
/** Walks JPEG segments to the first start-of-frame marker. Returns {0, 0} if none is found. */
private static int[] jpegSize(byte[] b) {
int i = 2;
while (i + 9 < b.length) {
if (u(b, i) != 0xFF) {
i++;
continue;
}
int marker = u(b, i + 1);
if (marker == 0xFF) {
i++;
continue;
}
if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) {
i += 2;
continue;
}
int len = (u(b, i + 2) << 8) | u(b, i + 3);
boolean sof = marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
if (sof) {
int h = (u(b, i + 5) << 8) | u(b, i + 6);
int w = (u(b, i + 7) << 8) | u(b, i + 8);
return new int[] {w, h};
}
i += 2 + len;
}
return new int[] {0, 0};
}
private static int u(byte[] b, int i) {
return b[i] & 0xFF;
}
private static int be32(byte[] b, int i) {
return (u(b, i) << 24) | (u(b, i + 1) << 16) | (u(b, i + 2) << 8) | u(b, i + 3);
}
}
@@ -0,0 +1,93 @@
package com.ankurm.imagegen;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Map;
import java.util.regex.Pattern;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
/**
* Writes generated images to an S3-compatible bucket (AWS S3 or MinIO).
*
* <p>No part of the object key is ever taken from the caller. The owner is a server-side id that
* must match {@link #OWNER}; the name is 128 random bits from {@link SecureRandom}; the extension
* comes from the sniffed type. A client that wants an image back sends the 32-hex id and we rebuild
* the key, so {@code ../} and absolute paths have nowhere to go.
*/
public final class ImageStore {
public static final Pattern OWNER = Pattern.compile("[a-z0-9][a-z0-9-]{0,31}");
public static final Pattern ID = Pattern.compile("[0-9a-f]{32}");
private static final SecureRandom RANDOM = new SecureRandom();
private final S3Client s3;
private final S3Presigner presigner;
private final String bucket;
public ImageStore(S3Client s3, S3Presigner presigner, String bucket) {
this.s3 = s3;
this.presigner = presigner;
this.bucket = bucket;
}
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);
}
/** Rebuilds the key from a validated owner and id; the extension is looked up, not supplied. */
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;
}
public byte[] read(String owner, String id, String extension) {
return s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key(keyFor(owner, id, extension)).build()).asByteArray();
}
public String presignedGet(String owner, String id, String extension, Duration ttl) {
String key = keyFor(owner, id, extension);
return presigner.presignGetObject(GetObjectPresignRequest.builder()
.signatureDuration(ttl)
.getObjectRequest(GetObjectRequest.builder().bucket(bucket).key(key).build())
.build()).url().toString();
}
private static void requireOwner(String owner) {
if (owner == null || !OWNER.matcher(owner).matches()) {
throw new RejectedException("bad-owner", "owner must match " + OWNER.pattern());
}
}
static String sha256(byte[] b) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b));
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,46 @@
package com.ankurm.imagegen;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
/**
* Applies the policy to bytes: size cap, then sniff, then allowed type, then pixel cap read from
* the header (before any decoder allocates a bitmap), then a real decode for the formats the JDK
* can read. WebP is sniffed only, because the JDK has no WebP reader.
*/
public final class ImageVerifier {
private final ImagePolicy policy;
public ImageVerifier(ImagePolicy policy) {
this.policy = policy;
}
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;
}
}
@@ -0,0 +1,40 @@
package com.ankurm.imagegen;
import java.util.Set;
import java.util.function.Function;
import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.openai.OpenAiImageOptions;
/**
* Turns the caller's size and quality choices into {@link OpenAiImageOptions} - after checking
* them against an allow-list, because the provider's own error for a bad value arrives late
* (after the network round trip) and as an SDK exception.
*/
public final class OpenAiOptionsFactory implements Function<ImageRequest, ImageOptions> {
public static final Set<String> SIZES = Set.of("1024x1024", "1536x1024", "1024x1536");
public static final Set<String> QUALITIES = Set.of("low", "medium", "high");
private final String model;
public OpenAiOptionsFactory(String model) {
this.model = model;
}
@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();
}
}
@@ -0,0 +1,65 @@
package com.ankurm.imagegen;
import java.util.ArrayList;
import java.util.List;
import org.springframework.ai.moderation.Categories;
import org.springframework.ai.moderation.ModerationModel;
import org.springframework.ai.moderation.ModerationPrompt;
import org.springframework.ai.moderation.ModerationResult;
/**
* The moderation step Spring AI does not run for you. It asks a {@link ModerationModel} about the
* prompt before any image is requested and fails closed: if the moderation call itself throws, the
* prompt is refused rather than waved through.
*
* <p>This inspects the TEXT. It says nothing about the picture that comes back.
*/
public final class PromptGate {
private final ModerationModel moderation;
private final ImagePolicy policy;
public PromptGate(ModerationModel moderation, ImagePolicy policy) {
this.moderation = moderation;
this.policy = policy;
}
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));
}
}
private static List<String> flaggedCategories(Categories c) {
List<String> out = new ArrayList<>();
if (c == null) return List.of("unspecified");
if (c.isSexual()) out.add("sexual");
if (c.isSexualMinors()) out.add("sexual/minors");
if (c.isHate()) out.add("hate");
if (c.isHarassment()) out.add("harassment");
if (c.isSelfHarm()) out.add("self-harm");
if (c.isViolence()) out.add("violence");
if (c.isViolenceGraphic()) out.add("violence/graphic");
return out.isEmpty() ? List.of("unspecified") : out;
}
}
@@ -0,0 +1,16 @@
package com.ankurm.imagegen;
/** A request or a provider result was refused by one of our own checks. {@code reason} is a stable code. */
public class RejectedException extends RuntimeException {
private final String reason;
public RejectedException(String reason, String detail) {
super(reason + ": " + detail);
this.reason = reason;
}
public String reason() {
return reason;
}
}
@@ -0,0 +1,78 @@
package com.ankurm.imagegen;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* Downloads an image a provider returned as a URL (DALL-E 3 style {@code response_format=url}).
* The URL comes from a third party, so it is treated as untrusted input: https only, the host must
* be on a list, redirects are not followed, the read stops at the byte cap, and there is a timeout.
*/
public final class RemoteFetcher {
private final ImagePolicy policy;
private final HttpClient http = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(5))
.build();
public RemoteFetcher(ImagePolicy policy) {
this.policy = policy;
}
public byte[] fetch(String url) {
URI uri;
try {
uri = URI.create(url);
} catch (IllegalArgumentException e) {
throw new RejectedException("bad-url", "not a URI");
}
boolean httpsOk = "https".equalsIgnoreCase(uri.getScheme());
boolean httpOk = policy.allowInsecure() && "http".equalsIgnoreCase(uri.getScheme());
if (!httpsOk && !httpOk) {
throw new RejectedException("bad-scheme", String.valueOf(uri.getScheme()));
}
if (uri.getHost() == null || !policy.fetchHosts().contains(uri.getHost().toLowerCase())) {
throw new RejectedException("host-not-allowed", String.valueOf(uri.getHost()));
}
try {
HttpResponse<InputStream> r = http.send(
HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(15)).GET().build(),
HttpResponse.BodyHandlers.ofInputStream());
if (r.statusCode() != 200) {
r.body().close();
throw new RejectedException("fetch-status", String.valueOf(r.statusCode()));
}
return readCapped(r.body());
} catch (IOException e) {
throw new RejectedException("fetch-failed", e.getClass().getSimpleName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RejectedException("fetch-failed", "interrupted");
}
}
/** Reads at most maxBytes + 1 bytes so an endless body costs us one byte over the cap, not the heap. */
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();
}
}
}
@@ -0,0 +1,12 @@
package com.ankurm.imagegen;
/**
* What the bytes themselves say they are. {@code width} and {@code height} are 0 when the
* format's header was not parsed (WebP).
*/
public record Sniffed(String contentType, String extension, int width, int height) {
public long pixels() {
return (long) width * height;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.imagegen;
/** What we kept: where, what it really is, how big, and the provider's rewrite of the prompt if it sent one. */
public record StoredImage(String key, String id, String contentType, int bytes, int width, int height,
String sha256, String revisedPrompt) {}