Run the OpenAI-compatible chat front¶
Topics: operations, requests
Before starting, have:
- A running Horde with the
/v2/generate/chatroutes reachable over HTTP from the front. - Access to the Horde's primary Redis server.
- At least one text worker with chat enabled.
- The project's dependencies installed with
uv sync.
End state: a python -m horde.oai process answering GET /healthz, GET /v1/models and
POST /v1/chat/completions, subscribed to the horde's chat completion signals, and serving its API documentation at
/docs, /redoc and /openapi.json.
The front is a separate ASGI process. It does not hold a database connection or import anything from the Flask
application: it submits each chat request to the horde's native /v2/generate/chat/async, waits for the horde's
Redis signal on horde:chat:<request id> (polling the status route as a fallback), and returns an OpenAI response.
Client authentication and attribution¶
A client's Authorization: Bearer <key> becomes the horde apikey. A request without one uses the anonymous key.
The front's Client-Agent header is <application>:<version>:<user agent> via horde-oai-front.
Horde statistics use it to attribute traffic to the application and distinguish it from direct native calls.
The application value is selected in this order: X-Title, X-OpenRouter-Title, the host of HTTP-Referer,
then the host of Referer. These values use version 0. Without these headers, the value is
horde-oai-front with the Horde version. The user agent is the client's User-Agent, or unknown.
Control characters and colons are removed from each part. The whole value is at most 256 characters, with the user agent truncated first.
1. Configure the front¶
Every setting is an environment variable, read at start through pydantic-settings. A .env file in the working
directory is read too. A variable that is set but invalid stops the start with a validation error that identifies it.
Connections and listening¶
| Variable | Default | Meaning |
|---|---|---|
HORDE_OAI_HORDE_URLS |
http://127.0.0.1:7001/api |
Comma-separated horde API roots, used round-robin. A root that refuses the connection is skipped for that call. |
HORDE_OAI_LISTEN |
0.0.0.0 |
Address to bind. |
HORDE_OAI_PORT |
7002 |
Port to bind. |
HORDE_OAI_REDIS_URL |
unset | Redis URL of the horde's primary Redis, such as redis://redis:6379/7. Pub/sub ignores the logical database, so only the host matters. Unset, every request waits on the fallback poll alone. |
HORDE_OAI_STREAM_REDIS_DB |
7 |
The logical database for chat content deltas on the host of HORDE_OAI_REDIS_URL. This is the Horde's chat signal database. The front reads it on a separate client, whatever database the URL selects. Unused without HORDE_OAI_REDIS_URL. |
HORDE_OAI_REDIS_LIVENESS_SECONDS |
30 |
How long the signal subscription may remain idle before the front sends a PING, and how long the PONG may take. A missing PONG sets signal_subscribed: false on /healthz and the front subscribes again, so an unresponsive connection is detected even when the subscription is idle. The front also turns on TCP keepalive for this connection. |
HORDE_OAI_PROXY_PASSKEY |
unset | The front operator's service account proxy passkey. When set, the front sends it as Proxy-Authorization with the client's address as Proxied-For, so IP timeouts and suspicion apply to the client. |
Admission and request limits¶
| Variable | Default | Meaning |
|---|---|---|
HORDE_OAI_MAX_INFLIGHT |
1000 |
Open requests per process before 503 FrontAtCapacity. A request holds its slot until it ends, including its cancel, so when the horde stalls the cap trips at this value divided by the request budget per second. It detects a stall and does not limit throughput. It also sizes the cancel pool: one cancel slot per 20 open requests, at least 16. |
HORDE_OAI_MAX_INFLIGHT_PER_SOURCE |
5 |
Open requests per client address per process before 503 FrontAtCapacity. Checked before the global cap, and before any of the body is read. Users behind one egress address share it. |
HORDE_OAI_MAX_BODY_BYTES |
37748736 |
Largest request body, counted while it is received and before JSON is parsed. Larger is 413. The default is the native route's 36 MiB, room for five 5 MiB images as data URLs plus the text limit. |
HORDE_OAI_MAX_MESSAGE_COUNT |
1024 |
Most messages per request. More is 400. |
HORDE_OAI_MAX_TEXT_BYTES |
1048576 |
Most UTF-8 bytes of message text per request. More is 400. |
HORDE_OAI_BODY_READ_TIMEOUT_SECONDS |
30 |
Time allowed to receive the body. Slower is 408 RequestBodyTimeout. |
Request budgets and cancellation¶
| Variable | Default | Meaning |
|---|---|---|
HORDE_OAI_NONSTREAM_TIMEOUT_SECONDS |
300 |
Total budget of a non-streamed request, from before the submit. |
HORDE_OAI_STREAM_TIMEOUT_SECONDS |
600 |
Total budget of a streamed request, from before the submit. |
HORDE_OAI_FINAL_STATUS_TIMEOUT_SECONDS |
2 |
Allowance for the one status read made at the deadline. |
HORDE_OAI_CANCEL_TIMEOUT_SECONDS |
10 |
Budget of one cancel's HTTP call. The wait for a free cancel slot is bounded by the same length. A cancel that does not get a slot in time is not sent and is retried. |
HORDE_OAI_CANCEL_RETRY_SECONDS |
30 |
How often cancels that failed, timed out or did not start are sent again, until 20 minutes after the submit, when the horde has expired the request. |
HORDE_OAI_SHUTDOWN_DRAIN_SECONDS |
30 |
How long uvicorn waits for open requests on a clean stop before cancelling their handlers. |
HORDE_OAI_SHUTDOWN_CANCEL_SECONDS |
15 |
Process-wide bound for cancelling every request still open at stop. |
Streaming and status polling¶
| Variable | Default | Meaning |
|---|---|---|
HORDE_OAI_HEARTBEAT_SECONDS |
5 |
Interval of the keepalive comment on a streamed response. |
HORDE_OAI_DELTA_PACING_MAX_WINDOW_SECONDS |
2 |
Longest window over which a relayed stream spreads the text of one worker progress post. The window is the estimated interval between the worker's posts, up to this length. 0 sends each post as one chunk when it arrives. |
HORDE_OAI_DELTA_PACING_MAX_CHUNKS_PER_SECOND |
20 |
Maximum number of content chunks a paced stream sends per second of its window. |
HORDE_OAI_FALLBACK_POLL_SECONDS |
15 |
Base status poll interval when no signal arrives. It stretches to half the horde's wait_time, at most 60 seconds, and is shortened at random by up to a fifth. |
HORDE_OAI_SIGNAL_RECHECK_SECONDS |
0.5 |
Delay of the one extra status read after a terminal signal whose outcome the first read did not show. |
HORDE_OAI_FAIL_FAST_GRACE_SECONDS |
20 |
How long the Horde may report a lack of eligible workers, without a processing generation, before the request is cancelled and returns 503. |
HORDE_OAI_SUBMIT_RECOVERY_ATTEMPTS |
2 |
How many times a submit with a lost response is sent again with the same operation key, within the request's budget. 0 returns 502 at once. See Lost submits. |
Connection pools and proxy addresses¶
| Variable | Default | Meaning |
|---|---|---|
HORDE_OAI_NATIVE_MAX_CONNECTIONS |
200 |
Simultaneous submits, status reads and model lists to the horde, and the size of their pool. 32 of these are reserved for submits, so status reads cannot hold back a new request's submit. The value must be above 32. Cancels use a separate pool sized from HORDE_OAI_MAX_INFLIGHT. |
HORDE_OAI_FORWARDED_ALLOW_IPS |
127.0.0.1 |
Addresses uvicorn trusts for X-Forwarded-For. Set it to the listener's addresses: the resulting client address is the per-source admission key and the Proxied-For value. |
The proxy passkey belongs to a service account: PUT /v2/users/<user id> with {"generate_proxy_passkey": true},
sent with that account's API key, returns it. Without it, the horde sees every request as coming from the front's
address, and one abusive client can put that address in timeout for all of them.
Verification: uv run python -c "from horde.oai.settings import load_settings; print(load_settings())" prints the
settings used by the front, without the Redis URL or the passkey. An invalid setting produces an error with the
variable's name.
Reversal: unset the variables.
2. Start the front¶
From the repository root:
With docker compose, the aihorde_oai service runs the same command from the horde image and publishes port 7002:
Verification: the log shows Subscribed to chat signals on horde:chat:*, and
returns {"live": true, "ready": true, "signal_subscribed": true, "inflight": 0}. ready: false means the
process is at HORDE_OAI_MAX_INFLIGHT or stopping. signal_subscribed: false means Redis is unset or unreachable;
requests still complete, but only at the fallback poll interval.
The interactive API documentation is at http://127.0.0.1:7002/docs, and / redirects there. /redoc shows the same
description, and /openapi.json is the OpenAPI document itself. /healthz is left out of it, since only operators
call it.
Reversal: stop the process, or docker compose stop aihorde_oai. On a clean stop the front waits up to
HORDE_OAI_SHUTDOWN_DRAIN_SECONDS for open requests, then cancels every request still open on the horde within
HORDE_OAI_SHUTDOWN_CANCEL_SECONDS before closing its Redis subscription. A request whose earlier cancel failed or
timed out, even one with a returned response, gets its cancel sent once more. A cancel still in progress is not sent twice.
While the process runs, the same failed cancels are sent again every HORDE_OAI_CANCEL_RETRY_SECONDS.
At most 10,000 requests with returned responses are held for this. Past that the oldest is dropped with a warning.
After a crash, open connections are dropped. Their Horde jobs run to completion and are charged. Their replies are not collected.
3. Check the model list¶
Verification: the names match the horde's GET /v2/status/models?type=text&chat=true, which lists only models that
an online chat worker serves. An empty list means no chat worker is online. GET /v1/models/<model name> returns one
entry. The name may contain slashes. The list is cached for five seconds per process.
Each entry is built from the model's chat object in that list:
| Field | Value |
|---|---|
id, object, owned_by |
The model name, model, aihorde. |
created |
The horde's chat.created, or 0 when it records none. |
context_length, top_provider.context_length |
chat.max_context_length, the largest an online chat worker declares. |
top_provider.max_completion_tokens |
chat.max_length, the largest an online chat worker declares. |
architecture |
Text in and out. input_modalities adds image, and modality becomes text+image->text, when a worker declares the vision feature. |
supported_parameters |
max_tokens, temperature, top_p and stop, then seed, frequency_penalty, presence_penalty and reasoning_effort for each a worker declares as a feature. tools and parallel_tool_calls with the tools feature, and with them tool_choice under the tool_choice_enforced guarantee. response_format with json_object or json_schema. |
x_horde.workers |
chat.workers, the online chat workers serving the model. |
x_horde.queued |
The model's queue, which chat requests share with text requests. Absent when the horde reports none. |
The limits and parameters are availability hints: a request can still lack an eligible worker a moment later.
4. Send a chat request¶
curl -s http://127.0.0.1:7002/v1/chat/completions \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-d '{
"model": "<model-name from step 3>",
"messages": [{"role": "user", "content": "Say hello."}],
"max_tokens": 64
}' | jq .
Verification: the response is a chat.completion. Its id is chatcmpl- plus the Horde request id, and its
model is the model that generated the reply.
x_horde includes request_id, worker_id, worker_name, kudos (what the request
cost), charge_basis (the Horde's pricing basis, passed through unchanged),
estimated_usage and, when the horde supplies them, queue_wait_seconds and context_adjustments.
estimated_usage: true means the worker did not report token counts and usage is an estimate at three characters
per token. x_horde.notice appears when the worker did not return visible text. A sampling setting or the model choice
is the likely cause. Each choice includes the worker's native_finish_reason beside finish_reason.
The front logs one line per request:
Chat request <id> finished: streamed=False submitted_at=... woke_at=... woke_by=signal waited=...s. woke_by=poll
on most requests means signals are not arriving.
Streaming check¶
Add "stream": true (and optionally "stream_options": {"include_usage": true}) to receive server-sent events
instead: : horde queue_position=7 wait_time=42 comments while the request waits, then a role chunk, content
chunks, a finish chunk with x_horde, the usage chunk with choices: [] when asked for, and
data: [DONE]. A plain request gets its content as the worker posts progress. The text of each post is spread over the
estimated interval until the next post. A request with
tools or structured output gets the whole text in one chunk. The response includes Cache-Control: no-cache,
X-Accel-Buffering: no and x-should-retry: false.
The same request works from an OpenAI SDK by pointing its base URL at http://<front-host>:7002/v1 and using the horde
API key as the SDK's API key.
Reversal: none needed. A request the client abandons is cancelled on the horde.
5. Check the front against the OpenAI SDK¶
The conformance module drives the pinned official openai Python SDK against the real front and the real native app,
each served on a loopback port, with a fake chat worker answering every job, so it does not need an inference
backend. It runs
under the integration test stack, which provides Postgres, the front's Redis subscription and object storage for
chat images:
AI_HORDE_REQUIRE_TEST_DEPS=1 bash tests/run_with_test_stack.sh -- .venv/Scripts/python.exe -m pytest tests/integration/test_chat_front_sdk.py -p no:cacheprovider
On Linux or macOS the interpreter is .venv/bin/python.
The runner works from Git Bash as from Linux or macOS. When the Garage bootstrap fails it exits with an error and does not run the command, so a test run does not silently provision separate databases in place of the stack.
Verification: every test passes: the model list includes the worker's chat model and leaves out a model that is not served by any online
worker. A completion and a streamed completion
return the worker's reply, finish_reason stop and the worker's token counts. logit_bias is a BadRequestError
whose param is logit_bias. A model without an online worker returns 503 no_eligible_worker at once. And an
image sent as a data URL reaches the worker, which declares vision and the url image transport, as
a signed http(s) URL.
Reversal: the run creates and drops its database schema. The test stack stays up for later runs unless
run_with_test_stack.sh is given --down.
Request keys the front accepts¶
The front accepts model (or models), messages, stream, stream_options, max_tokens,
max_completion_tokens, temperature, top_p, frequency_penalty, presence_penalty, stop, seed,
reasoning_effort, n, tools, tool_choice, parallel_tool_calls and response_format.
It applies streaming options locally and forwards generation parameters to the Horde.
max_tokens and max_completion_tokens must agree when both are sent and lie between 16 and 16384. With neither, the
horde generates up to 512 tokens. n must be 1. stop is a string or up to four strings. stream_options needs
"stream": true.
Message roles are system, developer, user, assistant and tool. developer is stored as
system, so the developer role loses precedence over system messages. An assistant message may include tool_calls and a tool message
answers one by tool_call_id (see the chat front reference). Content is a string or an
array of text parts. A user message may also include image_url parts (see Image inputs).
Stock clients send some keys at their defaults. These are accepted and dropped: empty or null tools, null
tool_choice or parallel_tool_calls, response_format: {"type": "text"}, logprobs: false, top_logprobs: 0,
store: false and service_tier "auto" or "default". user and metadata are accepted and unused.
A non-null tool_choice, including "none", or parallel_tool_calls without tools returns 400
ChatRequestConflict. Unsupported top-level keys and content part types return 400 UnsupportedChatParameter
with the path in param, such as messages[1].content[0].type=input_audio. Unknown keys inside messages,
tool definitions and tool_choice are dropped. See Parameter screening.
The horde object takes worker restrictions and context options, along with conformance, reasoning visibility,
availability and kudos limits. dry_run and operation_key are refused on the front. See
Horde options for the complete list and defaults.
Image inputs¶
A user message may include {"type": "image_url", "image_url": {"url": ..., "detail": ...}} parts, with url a
data:image/...;base64, URL or an http(s) URL and detail optionally auto, low or high. The front forwards
them unchanged. It refuses before the submit a request with more than 20 image parts (400
TooManyExtraSourceImages), a data URL that decodes to more than 5 MiB, or data URLs that decode to more than 25 MiB
in all (400 SourceImageSizeExceeded, with param the path of the part's url). Data URLs do not count toward
HORDE_OAI_MAX_TEXT_BYTES.
The front does not have the user's trust status. The Horde therefore enforces the 5 MiB total for users who are
neither trusted nor patrons. It fetches, checks and stores each image, charges the extra
source image tax per image, refuses images over the user's total (400 SourceImageSizeExceeded), and sends the
image only to a worker that declares the vision
feature and an image transport.
Image refusals pass through with the horde's rc as code, such as
SourceImageUrlRefused for a URL at a private address or SourceImageResolutionExceeded, and 503
ChatImageStorageUnavailable when the image store refuses the image. A model whose list entry shows image in
architecture.input_modalities has such a worker online.
Errors the front returns¶
Every error is an OpenAI envelope, {"error": {"message", "type", "param", "code", "metadata"}}, with param and
code written as null when they do not apply. metadata holds what is known: request_id, the horde's rc,
retry_after, and submit_outcome: "unknown" with the request's operation_key when a submit was lost and recovery
did not settle it.
A Horde error keeps the Horde's status, and its
rc becomes code. A horde 429 keeps Retry-After and the X-RateLimit-* headers, and has code
rate_limit_exceeded when the Horde does not send an rc, such as for its per-route request limit. A horde 503 without
Retry-After (such as ChatAdmissionDisabled) gets Retry-After: 30. A status read that ends the wait with a
horde error other than 404, 429 or a 5xx, such as 401 or 403, cancels the request before the error is returned. A 404
means the horde no longer holds it.
Once the horde holds a request, or may hold one, every error includes x-should-retry: false, so OpenAI SDKs do not
retry into a second charged job. Errors before the submit do not include the header and are safe to retry.
| Status | code |
x-should-retry |
Cause |
|---|---|---|---|
| 400 | UnsupportedChatParameter |
A key, role, content part, value or size the service does not serve. param holds its path. |
|
| 400 | ChatRequestConflict |
max_tokens and max_completion_tokens differ, or only and ignore are both set. |
|
| 400 | BadRequest |
The body is not a JSON object or a value has the wrong type. | |
| 400 | TooManyExtraSourceImages |
More than 20 image parts. | |
| 400 | SourceImageSizeExceeded |
An image data URL decodes to more than 5 MiB. param is the path of the part's url. |
|
| 401 | InvalidAuthorizationHeader |
Authorization is present but not Bearer <key>. Omit it to use the anonymous key. |
|
| 404 | model_not_found |
The model id contains :. The front does not serve model variants. A horde 404 keeps the horde's rc as code. |
|
| 408 | ChatRequestTimeout |
false |
The request was still waiting after HORDE_OAI_NONSTREAM_TIMEOUT_SECONDS and one final status read. It is cancelled. An outcome reached by the deadline is returned instead. Retry-After: 30. |
| 408 | RequestBodyTimeout |
The body took longer than HORDE_OAI_BODY_READ_TIMEOUT_SECONDS to arrive. The request was not submitted. |
|
| 413 | RequestTooLarge |
The body exceeds HORDE_OAI_MAX_BODY_BYTES. |
|
| 502 | ChatRequestFaulted |
false |
The horde reports the request as faulted, or it expired on the horde. When the fault followed a refused worker reply, the message states the refusal and metadata.rc includes its return code. |
| 502 | ChatRequestNoGeneration |
false |
The horde reported the request done without a generation and not faulted, after a short recheck or a cancelled signal, for example after a maintenance cancel. It is cancelled. |
| 502 | HordeUnavailable |
No horde root in HORDE_OAI_HORDE_URLS accepted the connection. |
|
| 502 | HordeUnavailable |
false |
The submit was sent, a response did not arrive, and a recovery response did not arrive. The request may exist. metadata holds submit_outcome: "unknown" and operation_key. |
| 502 | OperationKeyConflict |
false |
A recovery attempt was refused because the operation key holds a different request. The front re-sends the identical body, so this is a front defect. metadata holds the rc and operation_key. |
| 503 | FrontAtCapacity |
The process or the client's address holds its maximum of open requests. Retry-After: 5. |
|
| 503 | no_eligible_worker |
false |
No online worker could serve the request for HORDE_OAI_FAIL_FAST_GRACE_SECONDS. It is cancelled. Retry-After: 30. |
| 503 | queue_unavailable |
false |
A horde.availability_mode: "fail_fast" request whose first status read showed zero eligible worker threads, or an estimated wait over the time left in its budget. It is cancelled. A first status without queue information, which a request just submitted reads until the horde's cached queue positions refresh, is read again for up to 2 s (POSITIONED_READ_WAIT_SECONDS) before applying the wait estimate. Retry-After is the estimated wait, or 30 with zero threads. |
A streamed request's headers go out after the first status read, so a refusal at that read (a declaration or limit
gap, queue_unavailable, or a status error) is the HTTP error in the table, as on a non-streamed request. In a
stream, an error after the headers arrives as one data: event with the same envelope and no choice, then
data: [DONE]. A streamed request has a separate budget, HORDE_OAI_STREAM_TIMEOUT_SECONDS.
A client retry after a lost response is a new request and can create a second charged job. The front does not deduplicate requests.
Lost submits¶
The front sends each client request with a fresh UUID as horde.operation_key. The horde claims the key for the
submitting user when it activates the request. A repeated key with the same body returns the first
request's {id, kudos} with status 200, without queuing or charging anything.
When a submit was sent and a response did not arrive (a read timeout, a reset or a dropped connection), the front sends the
identical body with the same key again to the next root, up to HORDE_OAI_SUBMIT_RECOVERY_ATTEMPTS times and only
while the request's budget lasts. When every root is out of the rotation after the failure, the attempt goes to them
anyway. Then:
- A response with an id, 200 for an existing claim or 202 when the lost submit did not reach the Horde, continues the
request as any submit does. The log shows
Recovered the lost submit of operation <key> on attempt <n>. - A connection failure, a 429 or a 5xx moves on to the next attempt.
OperationKeyConflictreturns 502, as in the table above.- Any other horde refusal is passed through with
x-should-retry: false,submit_outcome: "unknown"and the key, since the lost submit may still have queued the request. - With the attempts or the budget spent, the response is 502
HordeUnavailablewithsubmit_outcome: "unknown"and the key. The key finds the request, if one exists, inwaiting_prompts.operation_key.
A refused connection on the first submit is not a lost submit: the request did not reach a horde, and the 502 does
not include an x-should-retry header.
Two metrics count these cases (see Chat front reference):
| Metric | Counts |
|---|---|
horde.oai.submit.recoveries |
Recovered submits. horde.existing_claim is true when the Horde returned an existing claim (200) and false when it returned 202. First-try submits are not counted. |
horde.oai.client_retry_hints |
Chat requests arriving with x-stainless-retry-count above zero, the header OpenAI SDKs send on their own retries. |
Neither metric measures prevented duplicate requests from clients. A claim match shows that the Horde queued a request whose submit response was lost. A retry hint shows that a client SDK retried, for any cause.
Export telemetry¶
The front exports OpenTelemetry traces (one server span per request, /healthz excluded, and a client span per
call to the horde that the horde's spans continue), metrics, and its Loguru log records as OpenTelemetry logs,
through the same environment variables as the horde's Flask application. With none of them set it does not export
anything.
| Variable | Default | Meaning |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
unset | OTLP endpoint for every signal. Setting it, or one of the per-signal endpoints below, turns export on. |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
unset | Per-signal OTLP endpoints. |
AI_HORDE_TELEMETRY_ENABLED |
unset | true, 1 or yes turns export on without an endpoint variable. |
OTEL_SDK_DISABLED |
unset | true keeps export off whatever else is set. |
OTEL_SERVICE_NAME |
horde-oai |
Service name of the front's telemetry. Leave it unset where the horde's environment sets it, so the front is not reported as ai-horde. |
DEPLOYMENT_ENVIRONMENT |
development |
The deployment.environment resource attribute. |
OTEL_TRACES_SAMPLER_ARG |
1.0 |
Head sampling ratio, as for the horde. |
PYROSCOPE_ENABLED=true starts Pyroscope continuous profiling with the horde's PYROSCOPE_* variables (see
horde.telemetry). The application name is the front's service name, so its profiles are separate from the horde's.
Verification: with an endpoint set, a collector receives horde.oai.* metrics within the SDK's export interval
after the first request, one Chat request <id> finished: ... log record per finished request, and a trace per
request whose root is the front's POST /v1/chat/completions span with the horde's POST /api/v2/generate/chat/async
span beneath it. The metric and log field lists are in the Chat front reference.
Reversal: unset the variables and restart the front.