Send chat completions through the OpenAI-compatible front¶
Before starting, have:
- An OpenAI chat completions client with configurable base URL and bearer token.
- A Horde chat front's base URL, used below as
OAI_BASE_URL. The public service ishttps://oai.aihorde.net/v1. - A Horde API key, used below as
HORDE_API_KEY, or the anonymous key0000000000.
End state: the client sends POST /v1/chat/completions to the Horde, streamed or not, with tools, images and
structured output where a served model supports them, and it handles the Horde's refusals and retry headers without
creating duplicate charged jobs.
The front returns 400 for unsupported top-level parameters. Unknown keys inside messages, tools definitions
and a tool_choice object are dropped, as OpenAI does. The reasons are in the
chat requests explanation and
ADR 0029. The
tutorial walks the same features with the Python SDK from scratch.
Configure base URL, key and attribution¶
Set the client's base URL to OAI_BASE_URL and its API key to the Horde API key. The client sends it as
Authorization: Bearer <key>. With the Python SDK:
A request without Authorization uses the anonymous key, and so does Bearer 0000000000. Anonymous requests are
served after requests from users with kudos (ADR 0035).
An Authorization header that is not Bearer <key> is 401 InvalidAuthorizationHeader.
The Horde attributes traffic by Client-Agent, which the front composes from your headers as
<application>:<version>:<user agent> via horde-oai-front. The application is your X-Title header, else
X-OpenRouter-Title, else the host of HTTP-Referer, else the host of Referer. The user agent is your
User-Agent. Send X-Title with your application's name so Horde statistics count your traffic under it:
client = OpenAI(base_url="<OAI_BASE_URL>", api_key="<HORDE_API_KEY>", default_headers={"X-Title": "<your-app>"})
Verification: GET /v1/models with the key returns 200:
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $HORDE_API_KEY" "$OAI_BASE_URL/models"
prints 200. A malformed header prints 401.
Choose a model from the model card¶
GET /v1/models lists models served by at least one online chat worker. Models without an online worker are absent.
Model ids are the Horde's model names and may contain slashes. An id containing : is 404 model_not_found, since
the front does not serve model variants.
curl -s "$OAI_BASE_URL/models" | jq '.data[] | {id, context_length, max: .top_provider.max_completion_tokens, supported_parameters, x_horde}'
| Field | Meaning for a request |
|---|---|
x_horde.workers |
Online chat workers serving the model. |
x_horde.queued |
The model's queued work, shared with text requests. |
context_length |
The largest context any of those workers declares. |
top_provider.max_completion_tokens |
The largest max_tokens any of those workers declares. |
supported_parameters |
max_tokens, temperature, top_p, stop. Then seed, frequency_penalty, presence_penalty, reasoning_effort, tools, response_format when a worker declares the feature. parallel_tool_calls with tools. tool_choice only when a worker serving tools also declares the guarantee that enforces it (see Conformance). |
architecture.input_modalities |
Includes image when a worker accepts images. |
The fields are the maxima and union across the model's workers, and they are availability hints: a request at the
largest max_tokens fits only the workers that declare it, and the workers online can change before the request is
picked up. A request for a model without an online worker ends at once with 503 no_eligible_worker. A request
that cannot be served by the online workers ends with 503 no_eligible_worker after the front's grace, or at once with 404
no_worker_supports_features when the model's online workers lack a declaration it needs (see
Errors and retries).
Verification: your configured model appears in .data[].id, with x_horde.workers at least 1 and every optional
parameter sent by your client listed in supported_parameters.
Accept any of several models¶
Send models, a list of up to 5 model ids, in place of model when any of them will do. The request is served by
whichever listed model an online worker runs, and the response model reports the one that served it:
curl -s "$OAI_BASE_URL/chat/completions" -H "Authorization: Bearer $HORDE_API_KEY" -H "Content-Type: application/json" \
-d '{"models": ["<model-a>", "<model-b>"], "messages": [{"role": "user", "content": "Hi"}]}' | jq .model
Every listed id has to exist on the Horde: an unknown one is 404 with param at its entry, such as models[1], and
the request is not queued. More than 5 ids, or an empty one, is 400 UnsupportedChatParameter. Sending both model
and models is 400 ChatRequestConflict. The kudos quote uses the first listed model. The generation is charged at
the model that served it.
Verification: the printed model is one of the listed ids.
Request keys the front accepts¶
| Keys | Handling |
|---|---|
model or models, messages |
Required: exactly one of model and models (see Accept any of several models). messages holds at least one message. |
max_tokens, max_completion_tokens |
16 to 16384, default 512. See Completion limit. |
temperature, top_p, stop |
Forwarded. temperature ranges 0 to 2, top_p 0 to 1, and stop is a string or up to four strings. |
seed, frequency_penalty, presence_penalty |
Forwarded. A worker has to declare the feature for seed (any value) or a non-zero penalty. Penalties range -2 to 2. |
reasoning_effort |
Requested thinking budget. See Reasoning effort values. |
n |
Only 1. |
tools, tool_choice, parallel_tool_calls, response_format |
Forwarded unchanged to a worker that declares the feature. See Tools and Structured output. |
stream, stream_options |
Applied by the front. stream_options needs "stream": true. |
user, metadata |
Accepted and unused. |
horde |
The Horde extension object, below. |
Completion limit¶
max_tokens and max_completion_tokens accept 16 to 16384 and must agree when both are sent. The default is 512
when neither is sent. See Deliberate deviations for the minimum of 16.
The limit covers both reasoning and the answer (see Reasoning budget).
A value above the largest length declared by an online worker for the model is lowered to that length and
reported in x_horde.context_adjustments.
Above the free output allowance, the API key must have
enough kudos to cover the request. Otherwise the request is refused with 403 KudosUpfront. The allowance is
512 to 4096 by default, depending on the load on eligible workers.
Reasoning effort values¶
none, minimal, low, medium, high or xhigh, forwarded unchanged. A worker has to declare the
reasoning_effort feature for any value, none included. When you leave it out, any chat worker may serve
the request, and a worker that declares the feature receives medium, OpenAI's default, which koboldcpp
applies as a thinking budget of about half of max_tokens. none disables thinking. How much reasoning the
other values allow is the worker's mapping. Any other value is 400 BadRequest with param
reasoning_effort.
Message roles¶
Message roles are system, developer, user, assistant and tool, with these keys:
| Role | Keys |
|---|---|
system, developer, user |
role, content, name |
assistant |
role, content, name, tool_calls |
tool |
role, content, tool_call_id |
A developer message is sent to the worker as system. Content is a string or an array of text parts. A user
message may also include image_url parts.
Any other key on a message, and any unknown key inside a tool call, its function object or a content part, is
dropped and not forwarded, as OpenAI ignores it. A name on a tool message is dropped. tool_calls on a message
other than an assistant message is refused, as OpenAI refuses it.
Client defaults¶
Stock clients send some keys at their defaults. The front accepts these and drops them before the Horde sees them:
empty or null tools, null tool_choice, null parallel_tool_calls, response_format: {"type": "text"},
logprobs: false, top_logprobs: 0, store: false, and service_tier "auto" or "default".
Verification: a request with only model, messages and the keys above returns 200 with a chat.completion
body.
Keys the front refuses¶
Any other key, and any of the dropped keys at another value, returns 400 UnsupportedChatParameter, with param
containing the key's path. Examples include logit_bias, logprobs: true, functions, function_call, audio,
modalities, prediction, web_search_options and sampler extras such as rep_pen, top_k and min_p.
An unknown key inside stream_options or response_format is refused at its path, as OpenAI refuses it.
tool_calls on a message other than an assistant message is refused at messages[<i>].tool_calls, and an
unsupported role at messages[<i>].role. A refused content part is reported with its type, such as
messages[0].content[0].type=input_audio.
A value OpenAI refuses by range is 400 with OpenAI's message and param: temperature outside 0 to 2, top_p
outside 0 to 1, a penalty outside -2 to 2, n or a token limit below 1, and more than four stop sequences are
UnsupportedChatParameter, such as Invalid 'temperature': decimal above maximum value. Expected a value <= 2, but got
2.5 instead. An empty messages is BadRequest with OpenAI's message. A token limit from 1 to 15 is refused with
the Horde floor of 16.
Other 400 codes:
code |
Cause |
|---|---|
ChatRequestConflict |
max_tokens and max_completion_tokens differ. model and models are both set. horde.only and horde.ignore are both set. A tool_choice (none included) or parallel_tool_calls without tools, as OpenAI refuses them. A forced function the tools do not define. Under horde.openai_guards, a json_object request without the word "json" in its messages. |
BadRequest |
The body is not a JSON object, a value has the wrong type or range, neither model nor models is set, models lists a model twice, messages is empty, or a json_schema response format has no schema. |
Each refusal happens before the request is queued, so it is not charged and is safe to correct and resend.
Verification:
curl -s "$OAI_BASE_URL/chat/completions" -H "Authorization: Bearer $HORDE_API_KEY" -H "Content-Type: application/json" \
-d '{"model": "<model>", "messages": [{"role": "user", "content": "Hi"}], "logit_bias": {"50256": -100}}' | jq .error
returns "code": "UnsupportedChatParameter" and "param": "logit_bias". The Python SDK raises BadRequestError
with param == "logit_bias".
Sampling defaults and the horde object¶
The worker receives every sampling value, so the worker's defaults do not apply. An omitted key is sent with the neutral
value: temperature 1.0, top_p 1.0, frequency_penalty 0, presence_penalty 0, and no repetition penalty,
top_k or min_p filtering. max_tokens defaults to 512. Send temperature and top_p explicitly when the model's
recommended settings differ.
The horde object includes the Horde options OpenAI does not have a key for:
| Key | Default | Effect |
|---|---|---|
only |
none | A non-empty list of worker ids. Only these workers serve the request. |
ignore |
none | A non-empty list of worker ids. These workers do not serve the request. Cannot be combined with only. |
trusted_workers |
false |
true restricts the request to trusted workers. |
validated_backends |
false |
true restricts the request to workers on validated backends. |
slow_workers |
true |
false excludes workers the Horde marks slow. |
max_context_length |
none | An explicit context length. A worker serves the request only when its declared chat context is at least this. |
context_fit |
truncate_oldest |
Context sizing policy. See Context fitting options. |
conformance |
strict |
strict sends the request only to workers that follow every part of the OpenAI contract it relies on. best_effort also accepts workers that may deviate. See Conformance. |
openai_guards |
false |
true turns on the request refusals OpenAI applies and the Horde relaxes on purpose: a json_object response format whose messages do not mention "json" is 400 ChatRequestConflict. |
include_reasoning |
true |
false hides the model's reasoning, which is otherwise returned as reasoning_content. See Reasoning. |
max_kudos |
none | Positive integer cap on the estimate. See Kudos estimate cap. |
availability_mode |
wait |
wait queues the request for the front's budget. fail_fast refuses it at once with 503 queue_unavailable and a Retry-After when the online pool has zero eligible worker threads, or when the Horde's estimated wait exceeds the budget. See Errors and retries. |
Any other key in horde, including dry_run and operation_key, is 400 UnsupportedChatParameter.
With the Python SDK, send the object as extra_body={"horde": {...}}. Other clients add it to the JSON body.
Restrictions narrow the eligible workers, so a combination that is not supported by any online worker ends with
no_eligible_worker, or a declaration or limit error when the cause is reported.
Context fitting options¶
truncate_oldest grows the context as grow does, then drops whole turns from the oldest forward until the
messages fit, keeping every system and developer message and the latest user message with what follows it.
grow raises the context to fit the messages, up to the largest chat context an online worker for the model
declares, and refuses messages that do not fit at that bound. reject refuses messages that do not fit the
context.
Kudos estimate cap¶
A positive integer cap on the request's kudos estimate. A request whose estimate at current pricing exceeds it
is refused with 400 max_kudos_exceeded before it is queued, so it is not charged. metadata.kudos_estimate
and metadata.max_kudos hold both values. The estimate assumes the full max_tokens is generated, while the
charge is for the output estimate, so the check is conservative.
Check Horde options¶
Verification: x_horde.worker_id of the response is one of the only ids, and x_horde.context_adjustments lists
a worker_max_length entry when max_tokens was lowered, {"from", "to", "reason": "grow"} when the context grew
and a truncate_oldest entry when the oldest turns were dropped, null otherwise.
A long conversation can be sent whole. Under the default truncate_oldest, the Horde drops what does not fit:
completion = client.chat.completions.create(
model="<MODEL>",
messages=conversation,
)
for adjustment in completion.x_horde["context_adjustments"] or []:
if adjustment["reason"] == "truncate_oldest":
print(f"{adjustment['dropped_messages']} oldest messages were dropped")
Reasoning¶
A reasoning model, such as Qwen3, generates reasoning before the reply. The answer in content does not include that reasoning.
When the worker submitted reasoning, the assistant message has it in reasoning_content beside content, as
DeepSeek and OpenRouter return it. A stream sends it as delta.reasoning_content chunks before the content of the
same worker post, so a client that reads the field shows progress during a long reasoning phase. The OpenAI SDK
keeps the field in model_extra, streamed and whole. To hide the reasoning, as OpenAI's chat API does, send:
Either way, usage.completion_tokens_details.reasoning_tokens reports how many of the completion tokens were
reasoning when the worker counted them. The option does not change the work, its cost or the operation the request
is for.
Verification: a request to a reasoning model returns choices[0].message.reasoning_content. The same request with
include_reasoning: false returns a message without a reasoning_content key.
Reasoning budget¶
The model generates its reasoning within max_tokens, together with the answer, and the reasoning is charged like the
answer. Set max_tokens large enough for both.
When you leave out reasoning_effort, a worker that declares the feature receives medium. On koboldcpp that is a
thinking budget of about half of max_tokens. When the reasoning reaches it, the thinking is closed and the answer
follows. A worker that does not declare the feature does not receive a value, and the model reasons without a cap. An
omitted max_tokens defaults to 512 in both cases.
A reply with finish_reason length, an empty content and reasoning in reasoning_content ran out of tokens while
thinking.
It cannot be continued, because the OpenAI API cannot resume reasoning. Reasoning sent back inside an assistant
message becomes a previous turn, and reasoning_content on a request message is dropped. Send a fresh request with a
larger max_tokens or a lower reasoning_effort. none disables thinking.
Images¶
A user message may include {"type": "image_url", "image_url": {"url": ..., "detail": ...}} parts. url is a
data:image/...;base64, URL or a public http(s) URL. detail is optional (auto, low, high) and passed to the
worker. Send images only to a model whose architecture.input_modalities includes image.
| Bound | Refusal |
|---|---|
| At most 20 images per request | 400 TooManyExtraSourceImages |
| At most 5 MiB per image, decoded or fetched | 400 SourceImageSizeExceeded, param at the part's url |
| At most 25 MiB of images in all, decoded or fetched and again as the JPEGs the Horde stores, for trusted users and patrons. 5 MiB for anyone else | 400 SourceImageSizeExceeded. The message states the total and the cap, and param is the url of the data URL part that passes 25 MiB |
| At most 3072 by 3072 pixels | 400 SourceImageResolutionExceeded |
| A URL at a private, loopback or reserved address | 400 SourceImageUrlRefused |
| A URL answering other than 200 | 400 SourceImageUrlInvalid |
| Not readable as an image | 400 SourceImageUnreadable |
The Horde fetches a URL once at submit, with 2-second timeouts and at most 3 redirects, and stores each image for
the worker. Each image adds 5 kudos to the request's cost. Image parts in any other role are refused with their path,
such as messages[1].content[0].type=image_url.
Verification: the reply describes the image, and x_horde.kudos is higher than for the same request without it.
Tools and tool_choice¶
tools holds up to 128 functions, {"type": "function", "function": {"name", "description", "parameters",
"strict"}}, each name 1 to 64 characters of A-Z, a-z, 0-9, _ and -. Other keys on an entry or its
function are dropped. The parameters schema is forwarded unchanged.
tool_choice is none, auto,
required, or {"type": "function", "function": {"name": "<name>"}} to force one function. Other keys in that
object are dropped. parallel_tool_calls is a boolean forwarded to the worker with the tools. tool_choice and
parallel_tool_calls without tools are 400 ChatRequestConflict, as under OpenAI. A parameters schema may nest
at most 10 levels below its root. A model's card lists tools when a worker serves them.
A tool round:
- Send the request with
tools. A reply with calls hasfinish_reasontool_calls(orstopunder a forcedtool_choice),message.contentnull when there is no text, andmessage.tool_calls, each{"id", "type": "function", "function": {"name", "arguments"}}withargumentsa JSON string. - Run each call.
- Send the history again with the assistant message replayed as
{"role": "assistant", "content": null, "tool_calls": [...]}, then one{"role": "tool", "tool_call_id": "<id>", "content": "<result>"}per call.
The tool history must follow OpenAI's rule: the tool messages come right after the assistant message and
answer each of its call ids exactly once, in any order. A message arriving before every call is answered, a
second answer to one id, or a tool message with no open calls is 400 UnsupportedChatParameter with param
messages[<i>].role. An id that was not issued in the preceding assistant message has param
messages[<i>].tool_call_id. The messages use OpenAI's format.
With the Python SDK, appending the returned message object to the history replays it correctly, including a message returned by
get_final_completion() from a stream: the front drops the fields added by the SDK (refusal, parsed,
tool_calls[].index, function.parsed_arguments and similar).
Verification: the first reply's message.tool_calls[0].function.name is the function asked for, and the follow-up
reply has finish_reason stop with text and no tool_calls.
Conformance¶
By default ("conformance": "strict") a request goes only to workers that declare the guarantees it relies on: an
enforced tool_choice for required or a forced function, schema
adherence for a json_schema format with strict: true, and refusal of a prompt that does not fit (the worker does
not trim it). A request requiring a guarantee that is not declared by any online worker serving the model ends at once with 404
no_worker_supports_features, and the message lists the guarantee.
To accept workers that may deviate, for example a backend that renders the same prompt for every tool_choice, send:
Under best_effort the reply may not follow those parts of the contract. Features and limits still apply in both
modes: a worker that clamps the temperature when tools are present does not receive a tools request above its limit,
and a tools request above the limit of every online worker ends at once with 404 no_worker_supports_features.
tool_choice: "none" does not need a guarantee, since the worker does not receive tool definitions under it.
Verification: a request with a forced tool_choice that ended with 404 no_worker_supports_features under strict,
on a model whose card does not list tool_choice, is picked up under best_effort.
Structured output¶
response_format takes {"type": "json_object"} or {"type": "json_schema", "json_schema": {"name", "schema",
"description", "strict"}}, with schema required. A model's card lists response_format when one of its workers
serves either type. It does not say which, so a json_schema request can lack an eligible worker on a model whose
workers serve only json_object.
With "strict": true the schema follows OpenAI's structural rules: every object schema, nested ones included, sets
"additionalProperties": false and lists all its properties in required. A schema breaking them is 400
UnsupportedChatParameter with param response_format and OpenAI's message. A tool with "strict": true in its
function follows the same rules for its parameters, refused with param tools[i].function.parameters. A schema
may nest at most 10 levels below its root, strict or not.
A request may offer tools and set a response_format together. The format applies only to the final text answer,
as under OpenAI. Only a worker declaring tools_with_response_format serves such a request,
under either conformance mode. When the feature is not declared by any of the model's online workers, the request ends at once with
404 no_worker_supports_features.
OpenAI refuses a json_object request whose messages do not mention JSON. The Horde serves it. To get OpenAI's
refusal, send {"horde": {"openai_guards": true}}.
The compact JSON of tools, tool_choice and response_format together may be at most 256 KiB.
Verification: json.loads(choices[0].message.content) succeeds, and for json_schema the document has the schema's
required keys.
Streaming¶
With "stream": true the response is server-sent events in this order:
- Keepalive comment lines while the request waits, such as
: horde queue_position=7 wait_time=42, at an interval the deployment sets (5 seconds by default). - A role chunk,
delta: {"role": "assistant"}. - A reasoning chunk with the whole
delta.reasoning_content, when the model reasoned, unless you senthorde.include_reasoning: false. - A content chunk with the whole
delta.content, when the reply has text. - A tool-call chunk with
delta.tool_calls, when the reply has calls: every call whole, each with its 0-basedindex,id,typeandfunction, and no content in the same chunk. - A finish chunk,
delta: {}, withfinish_reason,native_finish_reasonandx_horde. - A usage chunk with
choices: []andusage, whenstream_options.include_usageis true. data: [DONE].
A request without tools or a non-text response_format can receive content during generation if the worker posts
progress. The stream starts with a role chunk. Each progress post, usually sent about once a second, is paced into
smaller chunks. Reasoning precedes the content from the same post unless horde.include_reasoning is false.
Before the finish chunk, the front sends any final text that was not covered by the relayed chunks.
For tools, structured output or a worker without progress posts, the worker generates the complete reply first. The text then arrives in one content chunk.
Every chunk uses the same id and system_fingerprint. The finish and usage chunks include the served
model. An SDK accumulating tool-call deltas by index reconstructs the calls from the single tool-call chunk.
In the Python SDK, client.chat.completions.stream(...) with get_final_completion() returns the assembled message.
A stream's headers go out after the front's first status read of the request. A refusal at that read
(queue_unavailable, no_worker_supports_features, no_worker_supports_limit, or a Horde error such as 404) is an
HTTP error with its status, body and Retry-After, as for a non-streamed request. Once the headers are out the status
is 200 whatever the outcome, and a failure is one data: {"error": {...}} event, with the same envelope as an error
response and no choices, followed by data: [DONE]. A hand-written parser checks each data: payload for a
top-level error.
Live content can end in such an event after some text arrived: code ChatStreamDiverged when the worker stopped and
the Horde would start the reply over with another, or when the final reply does not continue the text sent, and
ChatRequestFaulted when the request faults. Discard the partial text. The request is not retried on the same
stream. The OpenAI SDKs raise on the event.
Verification: a successful stream ends with a finish chunk containing x_horde.request_id, a usage chunk when
stream_options.include_usage is true, and data: [DONE]. A buffered text reply without reasoning has four JSON
chunks (role, content, finish, usage). Live progress can produce more content and reasoning chunks.
Errors and retries¶
Every error is {"error": {"message", "type", "param", "code", "metadata"}}, with param and code null when they
do not apply and metadata with the Horde's request_id and return code where known
(ADR 0034). A refusal
from the Horde keeps its status and has its return code (see README_return_codes.md at the repository root) as
code.
A Horde 429 keeps Retry-After and the X-RateLimit-* headers, and has code rate_limit_exceeded when the
Horde does not send a return code. A native payload validation failure, which does not include a return code, is 400
BadRequest with the failing key as param, such as temperature or max_tokens.
The front's own codes:
| Status | code |
x-should-retry |
Cause |
|---|---|---|---|
| 400 | UnsupportedChatParameter, ChatRequestConflict, BadRequest |
See Keys the front refuses. | |
| 401 | InvalidAuthorizationHeader |
Authorization is not Bearer <key>. |
|
| 400 | max_kudos_exceeded |
The request's kudos estimate at current pricing exceeds horde.max_kudos. The request was neither queued nor charged. param is horde.max_kudos, and metadata.kudos_estimate and metadata.max_kudos hold both values. |
|
| 404 | model_not_found |
The model id contains :. |
|
| 404 | no_worker_supports_features |
false after queueing |
Online workers serve the model, but required declarations are missing or every worker's tool temperature limit is exceeded. At the first status read, the request is cancelled without a grace period. A native admission refusal can return this error before queueing. |
| 400 | no_worker_supports_limit |
false after queueing |
Only the output or context size exceeds every eligible worker's limits. param is max_tokens or horde.max_context_length. See Size limit gaps. |
| 408 | RequestBodyTimeout |
The body arrived too slowly. The request was not submitted. | |
| 408 | ChatRequestTimeout |
false |
The request was still waiting at the front's deadline and was cancelled. |
| 413 | RequestTooLarge |
The body exceeds the front's byte limit. | |
| 502 | ChatRequestFaulted |
false |
The Horde reports the request faulted or expired. When the Horde refused a worker's reply, the message ends with the refusal and its return code, and metadata.rc holds that code, such as ChatSubmitNonconformant for a reply that broke the request's tool_choice or response_format. When a worker reported why it faulted the job, the message ends with the Horde's sentence for that reason and metadata.rc is ChatWorkerFault. |
| 502 | ChatRequestNoGeneration |
false |
The Horde ended the request without a reply and without a fault, such as a cancel by the Horde before any worker finished. It was cancelled. |
| 502 | HordeUnavailable |
No Horde root accepted the connection. The request was not submitted. | |
| 502 | HordeUnavailable |
false |
The submit was sent and a response did not arrive. metadata.submit_outcome is "unknown". |
| 503 | FrontAtCapacity |
The front or your address holds its maximum of open requests. Retry-After: 5. |
|
| 503 | no_eligible_worker |
false |
The model is not served by any online worker, so the submit is refused and the request is not created. Or the request could not be served by any online worker during the front's grace period, and it is cancelled. Retry-After: 30. |
| 503 | queue_unavailable |
false |
Under horde.availability_mode: "fail_fast", the first status read showed zero eligible worker threads, or an estimated wait over the time left in the budget. It is cancelled. A request just submitted may not report a queue position at first, so the front reads again for up to 2 s until one shows the queue. Retry-After is the estimated wait, or 30 with zero threads. param is horde.availability_mode. |
A request that faulted after the Horde refused its workers' replies reports the last refusal:
{
"error": {
"message": "The AI Horde reports request <request-id> as faulted after refusing a worker's reply (ChatSubmitNonconformant): The request's 'response_format' is 'json_object', so the reply content has to be JSON.",
"type": "api_error",
"param": null,
"code": "ChatRequestFaulted",
"metadata": {"request_id": "<request-id>", "rc": "ChatSubmitNonconformant"}
}
}
A request whose worker reported why it faulted the job reports that reason:
{
"error": {
"message": "The AI Horde reports request <request-id> as faulted after its worker reported a fault (ChatWorkerFault): The worker could not fit the prompt into its context.",
"type": "api_error",
"param": null,
"code": "ChatRequestFaulted",
"metadata": {"request_id": "<request-id>", "rc": "ChatWorkerFault"}
}
}
Retry rules:
- An error without
x-should-retrycame before the Horde held the request. The request was not queued or charged, and a retry afterRetry-Afteris safe. - An error with
x-should-retry: falsedisables automatic retries in OpenAI SDKs. Usually the Horde held the request, or may have. A retry is a new request and can create a second charged job, since the front does not deduplicate client requests. The front also sets this header onno_eligible_workerrefusals before queueing, to prevent immediate retries against an unchanged worker pool. no_eligible_workermeans the request as sent does not have a worker online: check the model card for the model's workers andsupported_parameters, and change the request or wait for workers. The same request fails the same way until a worker comes online.queue_unavailablestates the pool at the first status read: eligible worker threads, the estimated wait and the budget left. Retry afterRetry-After, or sendavailability_mode: "wait"to queue for the budget.no_worker_supports_featureslists what is missing. Drop or change the feature, lower the temperature, or sendhorde.conformance: "best_effort"when the missing values are guarantees. The same request fails the same way until a worker declaring them comes online.- A stream always includes
x-should-retry: false.
Verification: a request for a known model that is not served by any online worker returns 503 with "code":
"no_eligible_worker" at once. The message states that the model is not served by any worker:
curl -s -i "$OAI_BASE_URL/chat/completions" -H "Authorization: Bearer $HORDE_API_KEY" -H "Content-Type: application/json" \
-d '{"model": "<an unserved model>", "messages": [{"role": "user", "content": "Hi"}]}' | grep -i -E "^HTTP|x-should-retry|retry-after|no_eligible"
Limits¶
| Limit | Value | Refusal |
|---|---|---|
| Request body | 36 MiB by default. The deployment sets it | 413 RequestTooLarge |
| Messages per request | 1024 | 400 UnsupportedChatParameter |
| Message text | 1 MiB of UTF-8 across all text and the function names and arguments of replayed tool calls, image data URLs excluded | 400 UnsupportedChatParameter |
| Images | 20 per request, 5 MiB and 3072 by 3072 pixels each, 25 MiB in all (5 MiB for untrusted users) | See Images |
| Tools, tool choice and response format | 256 KiB of compact JSON together. 128 tools. Schemas nested at most 10 levels | 400 UnsupportedChatParameter |
| Tool calls replayed in one assistant message | 128 | 400 |
max_tokens |
16 to 16384 | 400 UnsupportedChatParameter |
max_tokens against workers |
At most a worker's declared length. The card's max_completion_tokens is the largest |
Not refused. It is lowered to the largest declared length and reported as a worker_max_length context adjustment |
| Non-streamed and streamed wait | 300 and 600 seconds by default. The deployment sets them | 408 ChatRequestTimeout, or an in-stream error |
max_tokens is the limit clients most often exceed. It is an upper bound in the OpenAI API. A client default above
every online worker's declared length is lowered to the largest length declared by an online worker for the model.
The same applies to the 512 sent when max_tokens is omitted. x_horde.context_adjustments then holds
{"from": <requested>, "to": <declared>, "reason": "worker_max_length"}. A reply that needs more tokens ends with
finish_reason length. When the workers change their declarations after the submit, the request can still end
with 400 no_worker_supports_limit and param max_tokens.
Verification: a request above the card's max_completion_tokens is served, and its x_horde.context_adjustments
holds a worker_max_length entry whose to is the card's value.
Deliberate deviations¶
The front differs from OpenAI on purpose in these points:
max_tokensandmax_completion_tokensbelow 16 are refused, where OpenAI accepts any value from 1. 16 is the minimum generation length of the Horde's native text API, and raising a smaller limit would generate more tokens than the client asked for. The message states the floor.parampaths into messages are writtenmessages[2].role, where OpenAI writesmessages.[2].role. Paths into tools match OpenAI's form, such astools[0].function.parameters.- Refusals use the front's codes (
UnsupportedChatParameter,ChatRequestConflict,BadRequest) in place of OpenAI's per-failure codes such asdecimal_above_max_value. Message andparamfollow OpenAI's. system_fingerprintis a short hash of the serving worker id and model, null when the reply does not include a worker id. It changes when another worker serves the model. It does not change when a worker changes its backend under the same id.- A stream includes each reply's tool calls whole in one chunk, where OpenAI streams the arguments in fragments.
- The first streamed chunk includes only
rolein itsdelta, with nocontentkey. - Live content arrives in batches of about a second, and only for requests without tools or structured output.
- A
json_objectrequest whose messages do not mention JSON is served, where OpenAI refuses it, unlesshorde.openai_guardsis set. - Reasoning text is returned as
reasoning_content, where OpenAI does not return it. The OpenAI SDK keeps the field inmodel_extra, andhorde.include_reasoning: falsehides it.
Reversal¶
A client request does not change Horde state beyond the request itself. Point the client's base URL back at its previous provider to stop using the Horde. A request the client abandons by closing the connection is cancelled on the Horde. A streamed request cancelled while a worker generates is charged for the content the worker streamed before the cancel, priced as a reply of that length.
Related¶
- Chat front reference: request keys, message roles, response, stream and model card shapes.
- Chat requests reference: the native contract behind the front, including worker eligibility and pricing.
- Add chat completions to a web frontend: the same contract from a browser client.