From 03621752e8e794c60f537753b3d8e6c1b41486bc Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 14:53:04 +0200 Subject: [PATCH] feat: image fallback chains for every workflow, and a library worth looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Fallbacks.** One image model meant a refusal, a rate limit or a model the gateway had since dropped ended as a missing picture. Every workflow now tries its model, then each fallback in order, stopping at the first that produces an image. Primary plus two, capped: each hop is a paid request, and a chain long enough to need a cap is long enough to surprise someone. My Resources previously had no fallback at all — only the Clinical Assistant did, and only one. That is backwards: a missing figure is most visible in a deck, where it leaves a hole in a slide. The retry rule is now a classifier that says *why*, rather than a boolean. Transient faults, a 404 for a model the gateway does not have, and a content refusal all move to the next model — a refusal because policy is a vendor decision, not a fact about the request. 401/403 stop immediately (one gateway, one set of credentials, the next model fails identically), as do 413 and any other 4xx, which are malformed everywhere. Refusals are recognised from the message: no provider sends a machine-readable reason and the status varies. Each hop re-leases the job, so a chain cannot outlive its claim and let a second worker repeat the same paid work, and the row records the model actually being paid for so a picture made by the third model is not attributed to the first. The old singular `fallback_image_model` is still read, so an existing configuration keeps working without anyone re-entering it. **Library.** Documents/Images tabs in My Resources, with a real grid: fixed aspect tiles so the rows line up whatever shape the pictures are, a source badge on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails land, and a lightbox that closes on Escape or the backdrop and restores focus. Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete behind :hover would put it out of reach on touch and keyboard. Downloads go through privateImageBlob rather than a bare ``: a mobile client's session is a bearer token an anchor cannot send, and these assets are served no-store on purpose. The gallery lives in My Resources only. Assistant images appear in it, which was the point; the assistant page does not grow a gallery of its own, and a test asserts no assistant module lists the endpoint. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/my-resources.md | 31 ++++++ public/components/my-resources.html | 2 +- public/css/styles.css | 61 +++++++++++ public/js/admin/imageSettings.js | 143 ++++++++++++++++++++----- public/js/myResources.js | 157 ++++++++++++++++++++-------- src/routes/generatedImages.js | 50 ++++++++- src/utils/generatedImages.js | 134 ++++++++++++++++++++---- test/frontend-prompt-env.test.js | 28 +++-- test/image-fallback-chain.test.js | 100 ++++++++++++++++++ test/image-library.test.js | 52 ++++++++- 10 files changed, 653 insertions(+), 105 deletions(-) create mode 100644 test/image-fallback-chain.test.js diff --git a/docs/my-resources.md b/docs/my-resources.md index 5e9fe766..b42ab794 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -272,6 +272,37 @@ tiles cost a few kB each rather than thirty full-size downloads. Every fetch goes through `hydrateImage`, never a bare `src`: assets are served `no-store` and a bare `src` would not carry the session on a mobile client. +### Image model fallbacks + +Every workflow tries its configured model first, then each fallback in order, +stopping at the first that produces an image. Primary plus two, capped — each +hop is a paid request. Set in **Admin → Image models**. + +A fallback is only tried where another model has a real chance: + +| Failure | Next model? | Why | +|---|---|---| +| Timeout, 429, 5xx, network fault | yes | The provider said "not now", not "not ever" | +| 404 — the gateway does not have that model | yes | A configuration mistake the next model rescues | +| A content refusal | yes | Policy is a vendor decision, not a fact about the request | +| 401 / 403 | **no** | One gateway, one set of credentials; the next model fails identically | +| 413 — too large | **no** | It is too large everywhere | +| Any other 4xx | **no** | Malformed is malformed everywhere | +| Cancelled, or shutting down | **no** | Never start more paid work | + +A refusal is recognised from the message, because no provider sends a +machine-readable reason and the status varies — 400 from some, 422 from others. + +Each hop re-leases the job, so a chain cannot outlive its claim and let a second +worker repeat the same paid work; if the lease has gone the attempt stops there +rather than paying again. The row records the model actually being paid for, so +a picture made by the third model is not attributed to the first, and every hop +is logged with the reason it moved on. + +This used to be the Clinical Assistant alone, with one fallback. My Resources +had none at all — which is where a missing picture is most visible, because it +leaves a hole in a slide. + ### Deleting `DELETE /api/generated-images/:id` removes the bytes before the row, and refuses diff --git a/public/components/my-resources.html b/public/components/my-resources.html index b07dbba6..a2e2a9f8 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -177,7 +177,7 @@