With no eligible worker the free allowance takes its floor, and a request over it was refused for kudos that do not help until a worker that can serve it comes online.
- A text or chat request the kudos rules would refuse while no online worker passes the dispatch gates is refused 403 NoEligibleWorker (TextAsyncGenerate.refuse_when_no_worker_is_eligible). The waiting prompt is deleted and the decision counter records refused.
- The NoEligibleWorker message states the missing declarations or exceeded limits when they are the cause. The body carries impossible_reason in the status route's shape.
- handle_bad_requests merges an exception's body_fields into the error body. Exceptions without body_fields keep the message and rc body.
- Funded requests and requests within the allowance are created and queued whether or not a worker can serve them.
- The front reads impossible_reason from a native error body onto HordeAPIError. _horde_error maps NoEligibleWorker to no_worker_supports_features, no_worker_supports_limit or no_eligible_worker.
- The front's gap and no-eligible-worker errors take request_id and job_may_exist. A refusal at submit carries no request id and does not state that the request was cancelled.
- _size_limit_gap_chat_offers takes the gap directly, so a submit refused on a size limit gap states the largest declared limit.
- NoEligibleWorker is in KNOWN_RC, README_return_codes.md, ADR 0037, chat_requests.md and chat_front.md.
ChatProgressNotStreaming means stop posting and still submit, so a worker renewing an aborted job needs a separate code that means stop generating.
- A progress post without a stream lease reads the generation once. A faulted or missing generation returns 404 ChatJobAborted with the message "Generation <id> was aborted; stop generating and do not submit." A stored, unfaulted generation returns ChatProgressNotStreaming. Both the initial lease read and the race fallback after a failed append go through TextJobProgress.refuse_without_lease.
- is_aborted_generation in horde/classes/base/processing_generation.py holds the predicate (row missing or faulted). The submit claim path and the progress route share it.
- CHAT_JOB_ABORTED_RC and ProgressOutcome.ABORTED in horde/chat_progress.py count the refusal under horde.chat.progress_outcomes.
- ChatJobAborted is a known return code. README_return_codes.md lists it with the four ChatProgress codes.
- docs/reference/chat_requests.md splits the 404 row into the two codes.
- tests/integration/test_chat_progress.py covers a streamed generation faulted for a lapsed lease and a generation whose request was deleted.
- `MAX_CHAT_IMAGE_COUNT` 20 for every user; the untrusted count rule is replaced by a total of decoded image bytes, `MAX_CHAT_IMAGE_TOTAL_BYTES_UNTRUSTED` 5 MiB and `MAX_CHAT_IMAGE_TOTAL_BYTES` 25 MiB for trusted users and patrons, applied to the images as sent and again to the JPEGs stored, since a compact PNG re-encodes far larger and the stored bytes are what storage, transfer and the worker's download cost
- data URLs are held to the total before anything is fetched; URL images add to a running total after each fetch; the front mirrors the 25 MiB total and leaves trust to the native route
- `CHAT_IMAGE_PROMPT_TOKENS` (512, the koboldcpp worker's own per-image estimate) enters the fit estimate through `estimate_chat_prompt_tokens`, so a many-image request is not estimated to fit when the worker will refuse it; `truncate_oldest` drops an image turn's tokens with the turn
- `load_chat_image` is split into `read_chat_image_bytes` and `decode_chat_image` so the sent total can be checked between fetch and decode; the span name is kept
- `chat_contract`: `MAX_CHAT_MODEL_COUNT` (5, the most models a text style lists), `BAD_REQUEST_RC` as the one home of the generic 400 code, and the shared `check_model_choice` rule: exactly one of `model` and `models`, each model listed once
- The native request takes `models` beside `model`; `build_model_names` returns the list, so pop, the context bound and the highest-multiplier pricing serve any listed model as the text path does, and the response reports the served model
- `build_operation_fingerprint` hashes the sorted list, so the same set in another order is the same operation and `model: "a"` keeps the hash of `models: ["a"]`
- `ensure_model_known` checks every entry and refuses the request at the first unknown one; `UnknownChatModel` quotes `models[i]`
- `calculate_kudos` reads the first listed model, as the text path does, and the generation is charged at the model that served it
- The OpenAPI `ChatGenerationInput` makes `model` optional beside a bounded `models`
- The front accepts `models` beside an optional `model` and forwards it; more than 5 entries or an empty entry is `UnsupportedChatParameter` at `models` or `models[i]`, and the colon refusal and native path errors report `models[i]` as `param`
- The size limit gap states the largest limit across the listed models, and relayed chunks carry the model the first delta reports
- Tests for the request rules, the fingerprint, pop of a listed model, the dry run, the unknown entry, the reordered replay, forwarding and the front refusals; the references, how-to, explanation and `README_return_codes.md` describe the list
- `ChatHordeOptions.availability_mode` (`ChatAvailabilityMode`: `wait`, `fail_fast`) states how a request is treated when the online pool cannot serve it within the client's budget, and `max_kudos` caps the kudos estimate a request may carry at creation; both act on admission and the wait, so the operation fingerprint leaves them out and neither is stored
- `HordeChatStatus` reads `eligible_workers`, `eligible_worker_threads` and `might_stall`, None from a Horde that omits them, so the front does not refuse on threads when a Horde omits them
- `ChatRequestWatcher.wait_for_admission` runs the wait up to the first-read decision: under `fail_fast`, before any generation is processing or finished, zero eligible worker threads or a `wait_time` over the time left in the budget raises `QueueUnavailableError`; the declaration gap check stays ahead of it and later reads keep the grace
- A request just submitted is absent from the cached queue positions until they refresh, so the wait rule decides on the first status with queue information (a position or wait above zero, or a generation processing or finished), reading again every `signal_recheck_seconds` for at most `POSITIONED_READ_WAIT_SECONDS` (2 s, the cache refresh); zero eligible threads refuses on the first read
- `_stream_completion` registers the completion listener and the delta reader and awaits `wait_for_admission` before the SSE response opens, so a declaration or limit gap, `queue_unavailable` or a status error is the same HTTP error, `Retry-After` and cancel on a streamed request as on a non-streamed one; errors after that read stay in-stream, and the delta subscription reads from the stream's start, so entries posted during the first read are relayed
- The front answers `QueueUnavailableError` with 503 `queue_unavailable`, `param` `horde.availability_mode`, and `Retry-After` set to the `wait_time` capped at `QUEUE_UNAVAILABLE_RETRY_AFTER_CAP_SECONDS` or the no-eligible-worker interval for zero threads, with a message stating the pool at that read; the row is cancelled
- `_run_until_disconnect` and `_settle_wait_failure` hold the disconnect race and the wait-error mapping both response modes share
- `ChatAsyncGenerate.refuse_over_kudos_cap` compares the rounded dry-run quote with `horde.max_kudos` once the row exists and deletes the row before any upload, kudos hold or activation, so a refused request is not stored or charged; a dry run is refused the same way and a capped dry run bypasses the quote cache
- `GenerateTemplate.quote_kudos` is the one computation of a request's quote, shared by the dry run and the cap check; `ChatKudosCapExceeded` (400) states the estimate at current pricing and the cap, and is registered with the error handler and listed in `KNOWN_RC` and `README_return_codes.md`
- `KudosCapExcess` writes the refusal message and `KUDOS_CAP_EXCESS_PATTERN` reads both values back, so the native wording and the front's reading live in one module; the front maps the refusal to 400 `max_kudos_exceeded` with `param` `horde.max_kudos` and the estimate and cap in `ErrorMetadata`
- The quote assumes `max_length` while the charge is the output estimate (ADR 0027), so the cap check is conservative
- Tests cover both refusals in both response modes, the positioned read and its bound, admission when the pool fits or a worker holds the request, `wait` unchanged, a Horde without the fields, the gap precedence, a refused capped request that does not leave a row or a tax, and the front mappings; the references, how-tos and explanation describe the options and codes
- `docs/explanation/chat_requests.md` shows the component topology, one request's sequence with the progress relay and the first-read refusal, the front's request states and lost-submit recovery, and explains admission under `availability_mode`, the kudos cap and reasoning
- Strict tool definitions (`function.strict: true`) pass the structural rules of a strict `response_format`, with OpenAI's wording at `tools[i].function.parameters`
- The front bounds every OpenAI sampling parameter with OpenAI's ranges and messages before the native call (temperature 0 to 2, top_p 0 to 1, penalties -2 to 2, `n` 1, at most 4 stops, at least one message); `max_tokens` below the native floor of 16 is refused with the floor stated; a native validation failure maps to the failing `param`
- Responses carry `system_fingerprint`, a short hash of the serving worker and model; the front reference and how-to list the deliberate deviations from OpenAI
- A refused worker reply records its message and return code on the request and the return code on the generation; a faulted request reports `fault_reason` and `fault_rc` on status and in the fault event, and the front's 502 states the refusal with the code in metadata
- A fault that follows a `ChatSubmitNonconformant` refusal counts in the worker's `nonconformant_replies`, exposed in worker details and the moderation report, and leaves the dropped-job counts unchanged, since the worker generated and the reply failed the contract; structural refusals count as dropped jobs
- A chat fault submit accepts an optional `fault_reason` from a fixed vocabulary (`prompt_overflow`, `unbuildable_grammar`, `tool_violation`, `reply_refused`, `generation_error`); a known value stores the server's sentence for it with return code `ChatWorkerFault`, and does not overwrite a reason the server recorded when it refused the same generation; an unknown value is ignored; a reason does not change how the fault is counted
- The front waits while jobs remain and checks a done status without a generation again before answering `ChatRequestNoGeneration`; cancelled requests without a result settle promptly, and completion mapping requires a generation
- Migration 5.1.15 adds `chat_fault_reason`, `chat_fault_rc`, `processing_gens.chat_refusal_rc` and `workers.nonconformant_replies`; docs and tests per item
Where OpenAI refuses a request shape, the Horde refuses it with OpenAI's wording and `param`, so a client written against OpenAI fails the same way on both services.
- The front drops unknown keys inside messages and their nested objects, inside `tools` definitions and inside the `tool_choice` object before validation, so they are not forwarded; the top level and every other object (`horde`, `stream_options`, `response_format`) keep refusing unknown keys, and `tool_calls` on a message other than an assistant message is refused; OpenAI's API draws the same line, and the OpenAI SDK replays its accumulated streamed reply with fields it adds (ADR 0029 amended)
- Shared rules in `chat_contract`, applied by the native route and the front: `tool_choice` or `parallel_tool_calls` without tools is refused; an assistant message's tool calls are answered at once, each id exactly once, by tool messages that answer only that message; a strict `json_schema` sets `additionalProperties: false` and lists every property in `required` at every object
- `horde.openai_guards`, off by default, turns on the OpenAI refusals the Horde relaxes on purpose, such as a `json_object` request whose messages do not mention JSON; it acts at admission and is not stored
- A request that forwards tools beside a response format needs the `tools_with_response_format` capability under either mode, so a worker that cannot apply the format to the final answer does not receive it and the front reports the declaration gap
- Schemas are bounded at `MAX_CHAT_SCHEMA_NESTING` levels during validation, before serialisation can exceed the recursion limit; NUL is refused in schema keys; tool-call names and arguments in the history count toward the text byte cap; the response-format schema does not count as prompt in the fit and usage estimates, since the reference worker installs a grammar and does not render it; more tools than the limit is `UnsupportedChatParameter`
- A body holding `NaN`, `Infinity` or `-Infinity`, which are not JSON, is refused like a body that is not JSON; `refuse_json_constant` in `horde/chat_contract.py` is the parse hook for the front's body, the native chat body on both the Content-Length and chunked paths, and reply parsing
- `ChatJsonSchema` reads its schema from the public key `schema` only, so `schema_` is an unknown key and refused with its path as `param`
- Tests cover each rule on both routes and both body paths; the references, how-tos, tutorial and explanation describe the rules
A capability says a worker acts on an input; a guarantee says the reply follows that part of the contract. Requests are routed by both under a conformance mode, and the submit checks what the reply itself shows (ADR 0036).
- Adopt horde_sdk's chat policy vocabulary (`ChatFeature`, `ChatGuarantee`, `ChatConformance`, `ChatWorkerLimits`) for the server's feature enum
- Workers declare guarantees in `chat_features` and `tools_max_temperature` in `chat_limits`; `prompt_jobs: false` makes a chat worker chat-only, which drops the plain branch of its pop query, refuses plain prompts in `can_generate` and leaves its threads out of the text model count
- Requests carry `horde.conformance`, `strict` by default; required guarantees are stored beside required capabilities in `chat_required_features`, and the forwarded tools temperature is stored once and compared with the worker's bound in the pop query and in `can_generate` under every mode
- Dispatch does not forward tool definitions under `tool_choice` `none`, and a choice of one function forwards only that function as `required`; stored extras keep the client's values; model cards list `tool_choice` and `parallel_tool_calls` only when an online worker declares the matching guarantee
- `FAULTED_GENERATIONS_LIMIT` is the one threshold for faulting a request with too many faulted generations; `ProcessingGeneration.fault_and_restart` claims a pending generation, requeues its job and applies the limit in one commit, for text and image fault submits and the stale-generation cleanup alike, so only the claim's winner restarts and records the job; stale image jobs keep their timeout retry policy
- Status reads completion from one snapshot of the request and its generation counts; a request completes successfully only once every result is stored and every job has ended, and cancellation stops dispatch and settles after in-flight jobs finish, keeping partial results; the pop commits its taken jobs with the first generation
- Native status reports `impossible_reason` when every worker the eligibility pass found was refused for its chat declaration, with the missing declarations and the exceeded limits; the front answers such a gap with 404 `no_worker_supports_features` at once, and a model without an online worker keeps the grace and the 503
- `verify_chat_reply` checks a reply against the stored extras: under both modes a call to a function the request did not offer or two calls sharing an id is `ChatSubmitInvalid`; under `strict` a call under `none`, a missing or other call under a forced choice, and content that is not the requested JSON or does not match a strict `json_schema` are `ChatSubmitNonconformant`
- Several calls under `parallel_tool_calls: false` are reduced to the first, and a reply to a forced function finishes with `stop`, both OpenAI's shapes; strict schemas are validated with jsonschema (a runtime dependency), with the validator the schema declares or Draft 2020-12 and references resolved only inside the schema
- Migration 5.1.15 adds `accepts_prompt_jobs`, `chat_tools_max_temperature`, `chat_conformance` and `chat_tools_temperature`; ADR 0036, the references, how-tos, explanation and return-code table describe the policy
- Tests cover routing per guarantee and mode, the fault limit and concurrent claims, cancellation, image faults, the gap on status and in the front, and each verification rule per mode
- Chat requests accept `tools`, `tool_choice`, `parallel_tool_calls` and `response_format`, `tool` messages and assistant `tool_calls`; the shapes and bounds live in `horde/chat_contract.py`, so the front validates the document the native route stores
- `waiting_prompts.chat_extras` holds the applied keys as the client sent them and reaches the pop payload unchanged; `tool_choice` `none` beside tools is kept, since dropping it would leave the tools under the worker's default
- `chat_required_features` is computed once at creation by `required_chat_features` and compared with the declaration in `chat_refusal_reason`, so eligibility does not load a deferred column; `tools`, `json_object` and `json_schema` join `seed`, the penalties and `vision`, and a tool turn in the history needs `tools` as well, since the worker's template renders it
- Submit takes `message.tool_calls` with a nullable content and `finish_reason` `tool_calls`; a tool-only reply is a result and is charged on the content plus each call's name and arguments
- The flattened prompt includes the tools, schema and tool-call JSON, so the estimate and the context fit account for what the worker renders
- The front forwards the keys and messages as sent; replies carry `message.tool_calls`, and the emulated stream sends text and indexed `delta.tool_calls` in separate chunks, as OpenAI does, because KoboldAI Lite reads tool calls only from content-free chunks; the output estimate counts the tool-call JSON
- Model cards list `tools`, `tool_choice`, `parallel_tool_calls` and `response_format` from the declared features
- Browser clients call the front with a bearer key, so preflights are answered and every response allows any origin, exposing `Retry-After` and `x-should-retry`
- Tests cover the request shapes, the stored extras, eligibility, tool-call submits and their charge, a tool round with the result sent back, a streamed tool call and a `json_schema` reply through the OpenAI SDK; the migration and reference pages describe the columns, keys, features and return codes
- A user message may carry `image_url` parts (a data URL or a public http(s) URL, optional `detail`); at submit each image is decoded or fetched through the guarded client (`image.fetch_public_image`), checked against the shared caps (`MAX_CHAT_IMAGE_BYTES`, `MAX_CHAT_IMAGE_COUNT`, `MAX_CHAT_IMAGE_COUNT_UNTRUSTED`), normalised and stored as `<request id>_chat_<n>.jpg`; the stored part references the key, the row does not hold bytes or the client's URL, and the flattened prompt renders `[image]` so the estimate and moderation see it
- Workers declare `image_transports` (`url`, `base64`) and the `vision` feature; a request with images is offered only to a worker declaring both, through a JSONB containment test in the queue query so the deferred messages are not loaded; the transport used is recorded on the generation
- At pop, after the generation row commits and the queue lock is released, a `url` worker receives a download URL signed for at least the job TTL and 600 seconds, and a `base64` worker receives a `data:image/jpeg` URL (ADR 0032)
- Each image is taxed as an extra source image through `GenerateTemplate.get_activation_kudos_adjustment`, counted in the dry-run quote
- Uploads happen after the inactive row exists, so an image does not exist without a row; a failed activation, a lost operation-key race and a store failure delete the submission's images, row deletion and expiry delete them, and a failed delete is queued in the bounded Redis set `horde:chat:image_delete_retry` and retried by the cleanup tick, so the bucket is not listed
- `MAX_CHAT_BODY_BYTES` is 36 MiB so five base64 images fit beside the text allowance; return code `ChatImageStorageUnavailable`; the migration adds `workers.image_transports` and `processing_gens.image_transport`
- The front forwards image parts verbatim, refuses them on any role other than `user` by path, and refuses an oversized data URL or too many parts with the native codes `SourceImageSizeExceeded` and `TooManyExtraSourceImages` before forwarding; image bytes do not count toward the text allowance
- The front takes the in-flight slot before reading the body and builds the credentials before it too, so a source at its cap or a malformed authorization does not cost body bytes; a client that disconnects during the upload is answered 499 and counted as a refusal
- Native 400s that quote a message path surface it as `param`
- Tests cover eligibility, both transports, the lock order, the address guard with a fake resolver, the tax, deletion and the retry set, JPEG encoding of RGBA, LA and palette images, front screening and the SDK conformance module's signed URL with `detail` preserved; `docs/reference/chat_requests.md` and `outbound_requests.md` describe the format, the transport and the fetch limits
- Input images on a chat request are not screened by the server or by a chat worker, while image workers screen their output and report `worker_csam`; the chat requests and moderation operations references state this
A chat request carries an OpenAI `messages` array to a worker that renders it with its model's chat template; every other part of it is a text request, so queueing, pricing, moderation and limits run on the text path unchanged (ADR 0019).
- `horde/chat_contract.py` holds `ChatHordeOptions`, the model of a request's `horde` object, and the caps `MAX_CHAT_MESSAGE_COUNT`, `MAX_CHAT_TEXT_BYTES` and `MAX_CHAT_BODY_BYTES`; it imports only pydantic, so the native route and the separate front validate against one model; `INTERNAL_HORDE_OPTION_KEYS` (`dry_run`, `operation_key`) are refused in a public body
- `KNOWN_RC` and `README_return_codes.md` add `UnsupportedChatParameter`, `ChatRequestConflict`, `ChatAdmissionDisabled`, `ChatDeclarationInvalid`, `ChatSubmitInvalid`, `ChatModelMismatch`, `UnknownChatModel`, `OperationKeyConflict` and `RequestTooLarge`; `exceptions.ServiceUnavailable` is a 503 with a return code for a route a deployment switches off
- `sql_statements/5.1.15.txt` (version 5.1.15) adds `waiting_prompts.is_chat` and `messages`, the worker's chat declaration columns and the `processing_gens` reply columns; every statement is idempotent and a metadata-only add under a lock timeout; `is_chat` and `accepts_chat_jobs` default to false, so existing rows stay text requests and workers that predate chat jobs are not handed one, and `messages` is deferred, so only an assigned request loads it
- The `assigned_*` generation columns hold the worker's declaration as it stood at assignment, so a later declaration change does not redefine work already handed out
- `POST /v2/generate/chat/async` (`ChatAsyncGenerate`) takes an OpenAI chat-completions body; `horde/classes/kobold/chat_request.py` validates the supported subset, refuses any other key, role, value or content part with `UnsupportedChatParameter` and the key in the message, and maps the request onto text params, model names and worker targeting; the text params are checked against the text payload model, so a chat request accepts the ranges a text request does
- `ChatCompletionRequest` refuses NaN and infinite sampling values, and `parse_chat_request` refuses NUL in every stored string with `NulByteInPayload` before a row exists, because PostgreSQL rejects NUL in text columns and in JSONB
- The flattened messages are stored as the prompt and feed only the token estimate, kudos and moderation; `mirror_prompt_rewrites_onto_messages` applies a moderation replacement to every message text, so a worker does not receive text validation did not see
- `TextAsyncGenerate` splits into `parse_request_args`, `queue_generation` and `get_waiting_prompt_arguments`, which the chat route overrides; a chat request's context grows only up to the chat context of the workers serving its model
- `ChatAsyncGenerate.ensure_model_known` refuses a model that neither the text model reference lists nor an online chat worker serves with 404 `UnknownChatModel`, after authentication, maintenance and the concurrency check and before context sizing, so a client can tell an unknown model from one without a worker online
- `waiting_prompts.operation_key` and `operation_fingerprint`, unique per user through a partial index built concurrently, let a native caller claim one submission: a repeated key with the same fingerprint (`build_operation_fingerprint`, canonical JSON of the normalised request) returns 200 with the existing id and does not create anything, admission off included; a changed request is 400 `OperationKeyConflict`; `TextWaitingPrompt._activate` writes the claim in the commit that activates and taxes the request, and `activate_waiting_prompt` resolves the unique-violation race by reading the claim again and dropping the losing row
- `read_chat_request_body` refuses a body above `MAX_CHAT_BODY_BYTES` with 413 before parsing, chunked bodies included; `waiting_prompts.context_adjustments` records how `context_fit` grew the context
- `GET` and `DELETE /v2/generate/chat/status/<id>` (`ChatAsyncStatus`) report `RequestStatusChat`; `get_text_wp_by_id` filters on `is_chat`, so each route family reports the other family's ids as not found; `HORDE_CHAT_ADMISSION_ENABLED` refuses chat submits with 503 `ChatAdmissionDisabled` while queued requests are still served
- Text pop takes a chat declaration (`chat_jobs`, `chat_features`, `chat_limits`, `model_id`); `TextJobPop.validate_chat_declaration` refuses one without limits or `model_id`, or with a `model_id` outside the worker's models; `TextWorker.check_in` assigns the declaration on every pop before the check-in debounce returns, so a pop without `chat_jobs` withdraws it, and unchanged values do not write the row
- Chat jobs run under the chat limits and text jobs under the text limits: the pop query offers a chat request only to a worker whose `chat_model_id` the request lists and whose limits fit it, `TextWorker.chat_refusal_reason` refuses a request needing a feature the worker did not declare, and the availability query applies the same filter, so a request's status counts only workers that can serve it
- A chat job's payload carries the messages and every sampling field with its default filled (`build_chat_job_payload`), added at pop from the deferred column; the pop response carries `chat: true` under `GenerationPayloadChat`, and a text job's response is unchanged
- A chat job is submitted with `message`, `finish_reason` and `model_id`, plus optional `native_finish_reason`, `prompt_tokens` and `completion_tokens`; `prepare_chat_submission` refuses a missing or invalid message or finish reason with `ChatSubmitInvalid` and another model with `ChatModelMismatch`, and replaces NUL with U+FFFD as a text submission does
- The message content is stored as `generation`, so every predicate that reads `generation` treats chat and text alike; `get_submission_columns` writes the reply in the compare-and-set update that claims the generation, so a refused submission does not write any of it
- `TextProcessingGeneration.get_charge_basis` is `OUTPUT_ESTIMATE`: chat generations are charged and rewarded by the text formula, and reported token counts are stored and shown without changing the charge (ADR 0027)
- `horde/chat_signal.publish_chat_event` publishes `{"id", "event", "generations"}` on `horde:chat:<request id>` for each `ChatEvent` (generation, done, cancelled, faulted) from logical database 7, after the commit that settled it; a message does not carry text, and publishing is best effort, since a subscriber that misses one still reaches the result through the status route
- `horde/image.py` and `horde/r2.py` normalise and store chat images as JPEG (`load_chat_image`, `normalize_chat_image` with the longest side at most `MAX_CHAT_IMAGE_SIDE` 2048, quality 90 with transparency flattened onto white, since the multimodal loaders workers run do not decode WebP), with upload, download and presigned-URL helpers; the request route refuses image parts, and the helpers serve the signed-URL transport of ADR 0032
- Tests: `tests/unit/test_chat_request.py`, `tests/unit/test_text_worker_eligibility.py`, `tests/integration/test_chat.py` (queueing, pop, submit, status, cancel, expiry, fault, charge, admission, signals, the model check, operation key claims and races, the body bound), `tests/integration/test_chat_migration.py` and the fake chat worker in `tests/integration/_fake_chat_worker.py`
- `send_public_request` resolves each URL hop once, refuses non-public addresses and connects to a checked IP through PycURL with the original HTTP and TLS hostname. Redirect targets pass the same checks to prevent DNS rebinding and private-network access.
- `is_public_address` checks an IPv6 address that carries an IPv4 address (IPv4-mapped, IPv4-compatible or NAT64 `64:ff9b::/96`) as that IPv4 address, since a NAT64 translator or tunnel routes it there.
- libcurl enforces transfer deadlines across TLS negotiation and incomplete HTTP responses. Connect timeouts and low-speed windows bound stalled transfers. Synchronous DNS lookup time counts against the deadline but cannot be interrupted.
- Source image URLs use a 10-second deadline, at most three redirects and a 5 MiB cap on declared and decoded response size. Refused URLs return `SourceImageUrlRefused`, and non-200 responses return `SourceImageUrlInvalid`.
- Generation and interrogation webhooks use HTTPS without redirects, a 6-second deadline per attempt and a 64 KiB response cap. Delivery uses a bounded background queue so subscriber latency does not block worker submission.
- Source images fetched from a URL are stored in R2 under `<id>.webp`, the key the R2 download and delete helpers read. Interrogation stores its URL sources there too, so workers receive a presigned link to the checked copy.
- Local HTTP and HTTPS tests cover address restrictions, authorization, response limits, deadlines and connection fallback after a refused or timed-out connect. Tests also verify certificate rejection, proxy bypass prevention and that a disconnected POST is not replayed.
- The outbound reference documents request policies, caller limits and the synchronous DNS limitation.
- `GET /operations/moderation/prompts` takes a repeatable `reason`; an unknown value is `400 InvalidModerationReason`
- `(reason, id)` index on `prompt_moderation_events` in the model and `sql_statements/5.1.14.txt`, so a page of a rare reason does not walk the common ones
- `hold_source` on each listed event (`note`, `account_status` or null), computed in the page query from the same predicates `_under_moderation_action` applies, so the listing never disagrees with retention; nothing is stored and there is no release
- the migration test applies every migration that changes a moderation table, now 5.1.12 and 5.1.14
- version 5.1.14
- draw_collection_style draws only among the collection's styles that declare every template field the request supplies and whose required fields it supplies (template_fields_fit); a collection whose styles declared different fields made the same request succeed or fail by chance
- a request whose fields no style accepts is refused every time with the new TemplateFieldsMatchNoCollectionStyle code, and a style whose stored declaration no longer validates is left out of the draw
- the lines draw_collection_style was added with are CRLF again, matching the rest of horde/apis/v2/base.py
- unit tests cover which fields fit a style; integration tests send repeated requests to a collection mixing a style with required fields and one without, and repeated requests with a field neither accepts
- validate_text_template_fields refuses a text style written with a format spec, a conversion, attribute or index access, a positional field, or a brace that does not parse, under the new StylePromptFieldInvalid code; a format spec would let a template make every request build an arbitrarily large string
- format_text_style_prompt parses the template with string.Formatter and fills only bare names, so a field stored before the check is sent as written instead of being run; a stored template whose braces do not parse is refused with StyleDeclarationInvalid
- ParamValidator.validate_text_prompt runs the check on create and patch
- the published text brace_handling, the style contract reference, the how-to and README_return_codes state the rule
- unit tests cover each refused form, the literal rendering of a stored one and the length a format spec no longer adds; integration tests cover the refusal on create and patch
- BadRequest and Forbidden take the log message as their second positional argument, so every raise that passed its code there reported the generic code; the thirteen remaining sites now pass it as rc
- the style codes that now reach clients (StylePromptMissingVars, SharedKeyInvalid, StyleGetMistmatch, ExampleURLAlreadyInUse, TooManyStyleExamples) are listed in README_return_codes; SpecialFieldNeedsSpecialUser, SpecialModelNeedsSpecialUser and SpecialForbidden are fixed the same way
- integration tests check the code for a text style prompt without {p}, an unknown shared key on style creation, and a text style read through the image route
- load_parameter_policy and load_template_fields validate against the style type's vocabulary and refuse the request with StyleDeclarationInvalid, identifying the style and the declaration, instead of letting a ValidationError surface as a 500 when the rules or the params model change after a style was written
- text_style_contract_vocabulary and image_style_contract_vocabulary move beside the params models they read, in kobold.py and stable.py, so the request path can import them without a cycle
- GenerateTemplate gains the style_contract_vocabulary hook that StyleContractArgs already defines, overridden per gentype, and apply_style_contract passes it through
- unit tests cover a policy outside the vocabulary, a ceiling from the other type, a reserved placeholder and a non-list value; an integration test writes an invalid policy to the row and sends a request under it
- merge_client_parameters drops a request param outside overridable under listed instead of refusing it, since clients send every param that has a default and refusing unlisted ones would refuse nearly every request; StyleParameterNotOverridable is retired
- StyleParameterPolicy accepts a ceiling only on a param the mode hands to the request: n under every mode, listed params under listed, any cappable param under all; any other ceiling would never be measured against anything and is refused when the style is written
- the reference page, the how-to traps, the response model descriptions and the unit and integration tests state the same rules
- integration tests for both types send the params body a generated client fills from the documented defaults, read from the payload model so the body tracks the API, and check what the worker is handed under each override mode
- A style can carry two optional declarations. A parameter policy sets which of a request's params are kept when the style is applied: `none` keeps the style's params and ignores the request's, `listed` takes only the params the policy lists, `all` takes any of them. Template fields declare the placeholders the style's prompt accepts beyond `{p}`, each with a description and whether a request has to supply it.
- A policy's `ceilings` is a map from param to the largest value a request may set. It limits the request's value only; the style's own params are not checked against it, and a value above a ceiling is rejected rather than clamped, because clamping would change the cost of a request silently. A ceiling on a param the current mode does not let a request set is accepted and has no effect, except for `n`, which always comes from the request and is capped under every mode.
- `StyleContractVocabulary` holds, for one style type, the params a policy may make overridable and the params it may cap, with the range each ceiling may take. It is passed to `StyleParameterPolicy` through the pydantic validation context, so `horde/classes/base/style_contract.py` does not import the API layer and a stored policy can be loaded without one. Text styles cap `max_length`, `max_context_length` and `n`; image styles cap `width`, `height`, `steps` and `n`.
- `styles.parameter_policy` and `styles.template_fields` are nullable JSON columns, so a style that declares neither behaves exactly as before. `sql_statements/5.1.13.txt` adds them by hand; a nullable column with no default is a catalogue-only change and can be applied before the code that reads it is deployed.
- Seven return codes are added to `KNOWN_RC` and `README_return_codes.md` for the rejections the endpoints and the request path raise.
- No endpoint reads the columns yet.
- Record each worker suspicion in worker_suspicion_events from report_suspicion, keyed by worker UUID without foreign keys, so the history outlives suspicion resets, worker deletion and account wipes; the stored name is cut to the workers table's length (MAX_WORKER_NAME_CHARACTERS), since check_for_bad_actor reports an overlong name before it shortens it.
- Keep the history unless HORDE_WORKER_SUSPICION_RETENTION_DAYS is set, then delete rows past that window in a bounded step of apply_evidence_retention (RetentionPolicy.worker_suspicion), keeping the history of flagged or suspicious owners like every other step; the window is at most 36,500 days (MAX_WORKER_SUSPICION_RETENTION_DAYS), so the cutoff stays inside the date range.
- Disclose what the history records and its period in the privacy document, rendered from the same policy (RetentionPolicy.worker_suspicion_days).
- Add GET /api/v2/operations/moderation/worker_suspicion_events, filtered by worker or owner and paged by before_id; each row carries worker_id, user_id and suspicion_id, since worker names are not unique over time.
- Define the online window (WORKER_ONLINE_SECONDS) and the name limit (MAX_WORKER_NAME_CHARACTERS) once in worker.py, used by is_stale, check_for_bad_actor and set_name as well as the overview.
- Add the paused-worker list to the moderation overview, filtered by worker type, suspicion reason and online state, with a sort and paused_workers_total.
- Add InvalidWorkerID and InvalidSuspicionID return codes.
- Add moderator-only GET /api/v2/operations/moderation/prompts (OperationsPromptEvents), a newest-first page of events with their notes, filtered by account, proxied account, address, reporting worker and capture time, and paged by before_id; the page reports the retention windows in force in its retention field.
- Match the ipaddr filter on the address pseudonym, falling back to the address only on events without one, so an event stays findable after its address is removed; a blank value filters nothing and a value that is not one IP subject is refused with 400 BadRequest, like the OperationsIP endpoints.
- Expose the pseudonym as the opaque ip_subject_key, filterable by exact value so a moderator can pivot on it after the address is gone, and a non-address origin as origin_text; an event_id filter serves the alert link; the listing and PromptModerationEvent API model are explicit field lists, so a column never reaches moderators by accident.
- Add POST /prompts/<id>/notes (OperationsPromptNotes, add_prompt_note), locking the event so the note is ordered against a concurrent anonymization; a note on an anonymized event is stored, and a note exempts its event from further retention steps.
- Limit each moderator resource to 120 reads and 30 writes a minute per API key and method (get_request_api_key_per_method), so notes on every event share one budget, and expose the rate limit headers to cross-origin clients.
- Add the InvalidOperationsLimit, InvalidOperationsCursor, InvalidModerationTimeRange, InvalidModerationNote, InvalidModerationAddressFilter, InvalidModerationEventID and ModerationEventNotFound return codes.
A request ID is a bearer token: anyone holding it can poll progress and collect
results, and that is intentional. It also meant the prompt and parameters behind
a request were unreadable to the client that submitted them, and that the owner
of a shared key could enumerate every request ID funded through that key from
their own account view. This adds a credentialed read of the submission, and
closes the enumeration path at the same time so the new read cannot widen it.
- Add GET /v2/generate/request/<id> and /v2/generate/text/request/<id>. The
response has the shape of the generation input so it can be posted back to the
async endpoint as-is; defaults the Horde filled in come back filled, and
source images come back as their storage references rather than base64
- assert_submitting_key decides who may read: a personal key reads requests it
submitted and requests submitted through its own shared keys, a shared key
reads only what it submitted, and the anonymous key is refused. Any other key
gets 403 NotRequestOwner
- WaitingPrompt.get_submitted_request rebuilds the body from the row, with image
and text overrides restoring n, seed, seed_variation and softprompt from their
own columns. derive_submitted_request_model derives the response models from
the input models, dropping dry_run, allow_downgrade, replacement_filter and
style, which leave no trace on the row
- Account views no longer list shared-key-funded request IDs in
active_generations, even for moderators. Cache keys for those views are
versioned so entries written before this change are ignored, and
refresh_cache clears the new keys
- GET /v2/sharedkeys/<id> accepts the owner's personal key and then adds an
active_usage summary (request, queued, processing and finished counts) without
IDs or contents. Credentialed lookups bypass the shared response cache and
every response is Cache-Control: private, no-store
- Add a partial index on waiting_prompts.sharedkey_id, declared in the ORM and
built concurrently in sql_statements/5.1.12.txt, so the usage summary is one
indexed SELECT
- Document both endpoints in README_integration.md, register NotRequestOwner in
KNOWN_RC and README_return_codes.md, and cover the key check, body
reconstruction, privacy and cache isolation in tests
KNOWN_RC is the API's published set of return codes: it feeds the swagger Enum and the SDK's RC type. Several emitted codes drifted from it:
- Seven carried a trailing period
- ControlNet-on-inpainting, remix, banned-client and anonymous- delete responses used names the list never declared
- Five codes the endpoints emit were missing from the list altogether.
To correct the problem:
- Emit the declared names (InvalidRemixModel, BadClientAgent, AnonForbiddenUserMod, and the punctuation-free extra-image/text codes)
- Add the five undeclared codes to KNOWN_RC and document every listed code the README lacked, using each raise site's message
- Test that every return-code literal in the source is in KNOWN_RC and that the list holds bare identifiers, so the two cannot drift again
- the QR workflow applies a ControlNet without a control_type, so validation accepts control_strength alongside either and the rejection names both conditions; the swagger description and consts comment say the same
- kudos prices a set control_strength on the sourceless QR path (unset still reads as 1.0, so existing QR requests price as before); a QR request that also carries a source image keeps today's pricing
- tests: QR accepted, other workflows and neither still rejected, QR held to the same range, QR pricing moves with the field
- `control_strength` (0.01..3.0, default None) on ModelPayloadRootStable beside `control_type`, so it reaches requests and pops; styles carry neither field and apply_style replaces params wholesale, so the field has no meaning there
- validation rejects the field without a `control_type` (ControlStrengthWithoutControlType) and out of range (ControlStrengthOutOfRange); kudos.py already reads the field and still falls back to denoising_strength when it is absent, so unset requests price as before
- BRIDGE_CAPABILITIES gains version 18 {"control_strength"}; the pop eligibility check, the sorted-wp query filter and the skip counter gate on it the way flow_shift does, since an older bridge would silently render at its own weight
- tests: unit coverage for the validator, bridge reference, kudos, worker eligibility, and controlnet gating; test_request_scheduling names the capability tier instead of literal versions now that 18 is a new tier
- README_return_codes and the extended image frontend how-to list the two return codes
- mirrors the reGen 17 backend contract by accepting flow shift from 0 through 100 only on Flux-family and Qwen graphs that actually apply it
- rejects mixed-model, custom and unsupported baseline requests instead of allowing the backend to log and silently ignore the requested shift
- documents the return codes and schema bound, and covers range, applicability, bridge gating and successful dispatch end to end
- accepts the backend's remaining sampler tier, sigma-generator schedules, per-solver knobs and flow shift, while preserving the legacy request vocabulary
- validates solver settings, sampler/scheduler pairings and model baselines from horde-sdk so unsupported requests fail explicitly instead of rendering with silently ignored inputs
- publishes the same typed constraints as an unauthenticated API document, giving clients one source for controls, costs, recommendations and presentation tiers
- separates denoising trajectory length from model-evaluation cost so usage accounting and worker timeouts scale with actual work without distorting model step limits
- maps the added samplers onto compatible trained kudos slots and extends regression coverage across validation, bridge capabilities, pricing, styles and the wire schema