Send chat images to workers by the transport each worker declares¶
Context and Problem Statement¶
A chat request may include images, as data URLs or as http(s) URLs. A worker serving the request needs the image
bytes. Some workers fetch a URL. Others parse only inline data URLs. A large request also makes a large pop
response, which the Horde builds on its hot path. How do image bytes reach the worker?
Decision Drivers¶
- The pop path must not read object storage while the queue row lock is held.
- Pop responses should stay small.
- A worker may fetch a URL or may parse only an inline data URL.
- A signed URL must stay valid until the worker fetches it, which may follow a wait in the worker's local queue.
- Image bytes must stay out of the request row, which every queue scan loads.
Considered Options¶
- Store images as JPEG at submit and send each worker the transport it declares: offloaded messages URL, per-image signed URL, or inline base64 read after the row lock is released
- A per-image signed URL for every worker
- Base64 inlined at pop, read from object storage under the row lock
- Base64 stored in the request row at submit
Decision Outcome¶
Chosen option: "Store images as JPEG at submit and send each worker the transport it declares".
At submit each image is checked against the caps below, normalised (longest side at most 2048), encoded as JPEG at quality 90 and stored in the source image bucket under a key the request row references. The row does not hold image bytes or the submitted URL. JPEG is decoded by every chat worker's image loader: koboldcpp reads images with llama.cpp's multimodal loader, which decodes PNG and JPEG and does not decode WebP.
An http(s) image URL is fetched
at submit by fetch_public_image in horde/image.py, following at most 3 redirects. A store failure deletes the row
and returns 503 ChatImageStorageUnavailable, and the request is not charged.
The caps are defined in horde/chat_contract.py and shared by the front and the native route:
| Cap | Value | Symbol |
|---|---|---|
| Images per request, for every user | 20 | MAX_CHAT_IMAGE_COUNT |
| Decoded or fetched bytes per image | 5 MiB | MAX_CHAT_IMAGE_BYTES |
| Image bytes per request, trusted users and patrons | 25 MiB | MAX_CHAT_IMAGE_TOTAL_BYTES |
| Image bytes per request, other users | 5 MiB | MAX_CHAT_IMAGE_TOTAL_BYTES_UNTRUSTED |
| Prompt tokens each image adds to the fit estimate | 512 | CHAT_IMAGE_PROMPT_TOKENS |
| Request body | 36 MiB | MAX_CHAT_BODY_BYTES |
The per-request totals apply to the images as sent and again to the JPEGs they are stored as, since storage, transfer and the worker's download cost the stored bytes. The per-image tokens match what koboldcpp's worker adds in its own context overflow estimate, so a request with many images is not estimated to fit a context the worker then refuses. The body cap fits a request at every cap as base64 data URLs plus the text allowance.
A worker declares the transports it accepts in the image_transports pop field, stored in the worker's
image_transports column. A worker receives a request with images only when it declares the vision feature and
the url or base64 transport, since messages_url covers only offloaded requests. At pop
select_chat_transport in horde/classes/kobold/chat_request.py chooses one transport, recorded in the generation's
image_transport column:
| Condition | Transport | The worker receives |
|---|---|---|
The messages were offloaded and the worker declares messages_url |
messages_url |
One signed URL to the messages array, with each image inline as a JPEG data URL, valid for 120 seconds |
Otherwise, the request includes images and the worker declares url |
url |
A signed download URL per image, valid for the job's TTL and at least 600 seconds |
Otherwise, the worker declares base64 |
base64 |
Each image inline as a data:image/jpeg;base64, URL read from object storage |
Signing is local and does not make a request. The base64 read and the signing run while building the pop response, after
the commits that assign the generation have released the queue row lock. An image URL lives at least as long as the
job's TTL, with a floor of 600 seconds (MIN_CHAT_IMAGE_URL_SECONDS), so a worker that queues the job behind another
still finds the URL valid.
The messages URL lives 120 seconds (CHAT_MESSAGES_URL_SECONDS) because a worker fetches
it right after the pop, and a short lifetime limits how long a leaked URL can be replayed. A worker that cannot fetch
a URL it was sent faults the job.
Messages offload is off by default (HORDE_CHAT_MESSAGES_OFFLOAD_ENABLED). When it is on, a request whose messages,
as compact JSON with images inline, are larger than HORDE_CHAT_MESSAGES_OFFLOAD_BYTES (256 KiB by default) has them
stored as one object at submit. This applies to text-only requests too. With HORDE_CHAT_MESSAGES_OFFLOAD_GZIP set,
the object is stored gzipped with Content-Encoding: gzip when that makes it smaller. A worker that does not declare
messages_url receives an offloaded request inline by its image transport. One fetch per job, bounded by a JSON
parser, spares the worker managing several partial downloads.
koboldcpp's embedded worker declares messages_url and base64, so it receives images inline in the pop response or
in the offloaded messages. The url transport serves workers that declare it.
Images are deleted after the request row. A key that the store fails to delete is retried from a Redis set by the cleanup thread.
Consequences¶
- Good: The pop does not read object storage under the row lock, and the request row does not hold image bytes.
- Good: A worker receives images by a means it supports.
- Good: With offload on, a worker that declares
messages_urlreceives large requests outside the pop response. - Bad: With offload off, a base64 worker receives megabytes of image data in the pop response.
- Bad: Object storage reachability is a worker requirement for
urlandmessages_url, and a failed fetch faults the job. - Bad: Each transport is a separate path to maintain and test.
Pros and Cons of the Options¶
Declared transport¶
- Good: Serves workers that fetch URLs and workers that parse only data URLs.
- Bad: More than one delivery path.
Per-image signed URL for every worker¶
- Good: The smallest pop response. Image bytes do not pass through the Horde at pop.
- Bad: Excludes workers that take images only inline, and needs one fetch per image.
Base64 at pop under the lock¶
- Good: Works with any worker that parses data URLs.
- Bad: An object storage read while the queue row lock is held.
Base64 in the row¶
- Good: Does not add pop-time work.
- Bad: Image bytes in Postgres, loaded on every queue scan.
Confirmation¶
tests/unit/test_chat_request.py (TestImageTransport, TestMessagesOffload, TestImageValidation,
TestImageTotalByTrust, TestChatPromptEstimate) fixes the transport choice, the offload threshold and gzip, and the
image caps. tests/integration/test_chat_images.py fixes the signed URL lifetime, the base64 data URL built after
the lock is released, the offloaded messages fetch and inline delivery to a worker without messages_url.
docs/reference/chat_requests.md (Images, Messages offload) and docs/how-to/serve_chat_jobs.md (Serve images)
record the public behaviour.