feat(text): size the free output allowance by the request's eligible capacity

- `require_upfront_kudos` takes the competing jobs ahead and the eligible threads of the request; `compute_free_output_allowance` falls linearly from `HORDE_TEXT_FREE_OUTPUT_CEILING` (4096) to `HORDE_TEXT_FREE_OUTPUT_FLOOR` (512) at `HORDE_TEXT_FREE_OUTPUT_SLOPE` tokens per competing job per thread, and returns the floor when no thread is eligible; the horde-wide queue formula is gone
- `initiate_waiting_prompt` measures worker availability once at the requested `max_length`, seeding the availability cache that the post-activation check now reuses, so a creation runs one eligibility statement instead of the activation's; an infeasible measurement keeps only the validity flag so the first status read sees a worker that just came online; a downgrade discards the measurement
- `count_competing_queued_jobs` sums the queued generations an eligible worker could take, in process over the queue cache; the horde-wide count stands in when no queue is stored
- `MAX_LENGTH_LIMIT` rises to 16384: a hold is required above the allowance and a worker's advertised `max_length` bounds what is served
- the `KudosUpfront` message states the tokens the request could have had free
This commit is contained in:
tazlin committed 2026-10-30 23:46:14 -04:00
1 parent dd546f0383
commit 4189a4db07
19 files changed
+497 -78

No files matched your search

+8
View File
@@ -267,6 +267,14 @@ the complete event has folded, including a transfer split across batches.
This is conservative by design: a queued credit cannot fund a new spend until it is projected. Conservatism can delay
work during projector lag, but it cannot authorize an overspend on the strength of unmaterialized income.
Not every request takes a hold. A text or chat request that asks for no more tokens than its free output allowance is
admitted without one and charged when its generations settle, so a requester with little or no balance can still use
idle capacity. The allowance falls from 4096 tokens toward 512 as competing jobs queue for the workers eligible for
that request, so free output is generous when those workers are idle and modest when they are busy
([reference](../reference/chat_requests.md#free-output-allowance)). A free request's charge can take the requester
below the account floor; projection then forgives the shortfall with a `FLOOR_ADJUSTMENT`, which is how free output is
paid for.
`effective_kudos` serves a different purpose. It adds all committed, unprojected currency deltas to the materialized
balance and clamps the result to the account floor. It is appropriate for a "new balance" response or diagnosis, not
for spend authorization, because it includes queued credits and does not represent holds.
+1 -1
View File
@@ -204,7 +204,7 @@ Reversal: the run creates and drops its database schema; the test stack stays up
The core OpenAI keys are forwarded: `model`, `messages`, `stream`, `stream_options`, `max_tokens`,
`max_completion_tokens`, `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `stop`, `seed` and `n`.
`max_tokens` and `max_completion_tokens` must agree when both are sent and lie between 16 and 4096; with neither, the
`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`, which loses its precedence over `system`; an `assistant` message may carry `tool_calls` and a `tool` message
+2 -2
View File
@@ -113,7 +113,7 @@ Verification: the printed `model` is one of the listed ids.
| Keys | Handling |
| --- | --- |
| `model` or `models`, `messages` | Required: exactly one of `model` and `models` (see [Accept any of several models](#accept-any-of-several-models)); `messages` holds at least one message. |
| `max_tokens`, `max_completion_tokens` | 16 to 4096 (see [Deliberate deviations](#deliberate-deviations)); equal when both are sent. Default 512 when neither is. |
| `max_tokens`, `max_completion_tokens` | 16 to 16384 (see [Deliberate deviations](#deliberate-deviations)); equal when both are sent. Default 512 when neither is. Above the free output allowance, by default between 512 and 4096 depending on the load on the workers that can serve the request (see [Free output allowance](../reference/chat_requests.md#free-output-allowance)), the key needs the request's kudos available, or the request is refused with 403 `KudosUpfront`. |
| `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` | `none`, `minimal`, `low`, `medium`, `high` or `xhigh`, forwarded unchanged with no default; a worker has to declare the `reasoning_effort` feature for any value, `none` included. `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`. |
@@ -476,7 +476,7 @@ curl -s -i "$OAI_BASE_URL/chat/completions" -H "Authorization: Bearer $HORDE_API
| Images | 20 per request, 5 MiB and 3072 by 3072 pixels each, 25 MiB in all (5 MiB for untrusted users) | See [Images](#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 4096 | 400 `UnsupportedChatParameter` |
| `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 | 400 `no_worker_supports_limit` on `max_tokens`, at once |
| Non-streamed and streamed wait | 300 and 600 seconds by default; the deployment sets them | 408 `ChatRequestTimeout`, or an in-stream error |
+30 -1
View File
@@ -186,7 +186,7 @@ returns `429`.
| `model` | The model the request accepts. A request sends exactly one of `model` and `models` |
| `models` | The models the request accepts, 1 to `MAX_CHAT_MODEL_COUNT` (5), each non-empty and listed once: the text request's `models`. Whichever of them an online worker runs serves the request, chosen at pop as for a text request, and the generation's `model` on the status route is the one that served it |
| `messages` | Required, at least one. Roles `system`, `developer`, `user`, `assistant`, `tool`; see [Tools](#tools) for the tool keys |
| `max_tokens`, `max_completion_tokens` | `max_length`, 16 to 4096, default `512`. Both may be sent only with equal values |
| `max_tokens`, `max_completion_tokens` | `max_length`, 16 to 16384, default `512`. Both may be sent only with equal values; above the [free output allowance](#free-output-allowance) the kudos are held upfront |
| `temperature`, `top_p` | The text params with the same keys |
| `frequency_penalty`, `presence_penalty` | -2 to 2; forwarded to the worker, which has to declare the feature for a non-zero value |
| `seed` | Forwarded to the worker, which has to declare the `seed` feature |
@@ -817,6 +817,35 @@ status does not list a generation for it, and `charge_basis` and `finish_reason`
generation. The Horde does not measure partial work on a request that does not stream: its worker submits after the
cancel and is paid for the reply as usual.
### Free output allowance
A text or chat request whose `max_length` is at most its free output allowance is admitted without holding its kudos
upfront and is charged when its generations settle. Above the allowance, the requester has to have the request's
kudos available, and they are held at admission. A chat request does not downgrade, so a requester who cannot cover
the hold gets `403 KudosUpfront`, whose message states the allowance. A text request with `allow_downgrade` is
lowered to the allowance instead.
The allowance is measured when the request is created, over the workers eligible for it at its requested
`max_length` (`TextWaitingPrompt.require_upfront_kudos`, `compute_free_output_allowance`):
```text
allowance = clamp(ceiling - slope * competing_jobs / eligible_threads, floor, ceiling)
```
| Input | Value |
| ----------------- | ----- |
| `eligible_threads` | The advertised threads of the eligible workers. With none, the allowance is the floor |
| `competing_jobs` | The waiting generations of every queued text request that accepts any model or a model an eligible worker serves, read from the in-process copy of the stored queue; the horde-wide queued text generations when no queue is stored |
| `ceiling` | `HORDE_TEXT_FREE_OUTPUT_CEILING`, default 4096: what an idle fleet serves free |
| `floor` | `HORDE_TEXT_FREE_OUTPUT_FLOOR`, default 512: the chat default `max_tokens` stays free under any load |
| `slope` | `HORDE_TEXT_FREE_OUTPUT_SLOPE`, default 1792: one competing job per eligible thread halves the 3584 tokens above the floor, and two reach it |
A request that does not allow slow workers always holds its kudos upfront. With `HORDE_UPFRONT_KUDOS_ON_WORKERLIST`
set to `1`, a request that lists workers holds its kudos upfront as well and cannot be downgraded. The availability
measured for the allowance is cached for the request's status and reused by the worker check after activation, so
creation runs the eligible-worker query once; a dry run runs it as well. A downgrade changes the eligible workers, so
the check after a downgrade queries them again.
## Chat model list
`GET /v2/status/models?type=text&chat=true` lists only the text models that at least one online chat worker serves,
+1 -1
View File
@@ -1341,7 +1341,7 @@ class TextModels(v2.Models):
"messages": fields.List(fields.Nested(self.model_chat_message), required=True, min_items=1),
"max_tokens": fields.Integer(
description=(
f"How many tokens to generate, 16 to 4096. Defaults to {DEFAULT_CHAT_MAX_TOKENS}. Must equal "
f"How many tokens to generate, 16 to {MAX_LENGTH_LIMIT}. Defaults to {DEFAULT_CHAT_MAX_TOKENS}. Must equal "
"max_completion_tokens when both are set."
),
),
+59 -6
View File
@@ -163,6 +163,9 @@ class TextAsyncGenerate(GenerateTemplate):
gentype = "text"
requires_chat_workers = False
"""Whether only workers that accept chat jobs can serve the request, which bounds how far its context grows."""
creation_availability: database.RequestWorkerAvailability | None = None
"""The eligible worker capacity measured when the request was created, which sets its free output allowance and
answers the worker check after activation; None until measured and after a downgrade changes the request."""
decorators = [
limiter.limit(
limit_value=lim.get_request_90min_limit_per_ip,
@@ -228,7 +231,11 @@ class TextAsyncGenerate(GenerateTemplate):
"id": self.wp.id,
"kudos": round(self.kudos),
}
if not database.check_new_request_has_valid_workers(self.wp) and not settings.mode_raid():
has_valid_workers = database.check_new_request_has_valid_workers(
self.wp,
creation_availability=self.creation_availability,
)
if not has_valid_workers and not settings.mode_raid():
ret_dict["message"] = self.get_size_too_big_message()
return (ret_dict, 202)
@@ -263,7 +270,8 @@ class TextAsyncGenerate(GenerateTemplate):
self.wp = TextWaitingPrompt(**self.get_waiting_prompt_arguments())
self.refuse_over_kudos_cap()
self.upload_source_images_or_delete_waiting_prompt()
_, total_threads = database.count_active_workers("text")
# Measured once here, at the requested max_length, and reused by the check after activation.
self.creation_availability = database.get_worker_availability_for_request(self.wp)
highest_multiplier = 0
if len(self.models) == 0:
required_kudos = 20 * self.wp.n
@@ -275,7 +283,10 @@ class TextAsyncGenerate(GenerateTemplate):
if model_multiplier > highest_multiplier:
highest_multiplier = model_multiplier
required_kudos = round(self.wp.max_length * highest_multiplier / 21, 2) * self.wp.n
needs_kudos, tokens, disable_downgrade = self.wp.require_upfront_kudos(database.retrieve_totals(), total_threads)
needs_kudos, tokens, disable_downgrade = self.wp.require_upfront_kudos(
competing_jobs_ahead=self.count_competing_jobs_ahead(),
eligible_threads=self.creation_availability.thread_count,
)
if self.sharedkey and self.sharedkey.kudos != -1 and required_kudos > self.sharedkey.kudos:
if self.args.allow_downgrade and not disable_downgrade:
self.downgrade_wp_priority = True
@@ -297,14 +308,15 @@ class TextAsyncGenerate(GenerateTemplate):
if reservation is None:
if self.args.allow_downgrade and not disable_downgrade:
self.wp.downgrade(tokens)
# The downgrade lowers max_length and allows slow workers, which changes the eligible workers.
database.discard_cached_worker_availability(self.wp)
self.creation_availability = None
else:
self.wp.delete()
raise e.KudosUpfront(
required_kudos,
self.username,
message=f"Due to heavy demand, for requests over {tokens} tokens, "
"the client needs to already have the required kudos. "
f"This request requires {required_kudos} kudos to fulfil.",
message=self.get_kudos_upfront_message(tokens, required_kudos),
)
if self.sharedkey:
@@ -328,6 +340,47 @@ class TextAsyncGenerate(GenerateTemplate):
Override where a request type accepts a client cap on its kudos estimate.
"""
def count_competing_jobs_ahead(self) -> int:
"""Return the waiting generations queued for the workers eligible for the request being created.
Every competing queued request counts, since the new request is not queued yet. When no queue is stored, the
horde-wide count of queued text generations stands in, which is never smaller than the competing count.
Returns:
The competing waiting generations; 0 when no worker is eligible, since the allowance is then the floor.
"""
if self.creation_availability is None or not self.creation_availability.is_possible:
return 0
competing_jobs = database.count_competing_queued_jobs(
self.wp.wp_type,
self.creation_availability.served_model_names,
)
if competing_jobs is not None:
return competing_jobs
return int(database.retrieve_totals()["queued_text_requests"])
def get_kudos_upfront_message(self, free_tokens: int, required_kudos: float) -> str:
"""Return the ``KudosUpfront`` message for a request whose requester cannot cover its kudos hold.
Args:
free_tokens: The most tokens the request could ask for without holding kudos upfront.
required_kudos: The kudos the request holds upfront.
Returns:
The message, stating the free allowance.
"""
if not self.wp.slow_workers:
return (
"A request that does not allow slow workers needs the client to already have the required kudos. "
f"With slow workers allowed, about {free_tokens} tokens would currently be served without it. "
f"This request requires {required_kudos} kudos to fulfil."
)
return (
f"The workers eligible for this request currently serve up to {free_tokens} tokens without kudos held "
"upfront. For more, the client needs to already have the required kudos. "
f"This request requires {required_kudos} kudos to fulfil."
)
def get_size_too_big_message(self):
return (
"Warning: No available workers can fulfill this request. It will expire in 20 minutes. "
+4 -2
View File
@@ -41,8 +41,10 @@ DEFAULT_MAX_LENGTH = 80
DEFAULT_MAX_CONTEXT_LENGTH = 2048
"""The ``max_context_length`` a request that sets none runs under. The same default ``TextWaitingPrompt`` uses."""
MAX_LENGTH_LIMIT = 4096
"""The largest ``max_length`` the params model accepts."""
MAX_LENGTH_LIMIT = 16384
"""The largest ``max_length`` the params model accepts. A request above its free output allowance holds its kudos
upfront, and the eligible workers' advertised ``max_length`` bounds what is served, so the cap does not need to sit
at what a typical worker serves."""
MAX_CONTEXT_LENGTH_LIMIT = 1_048_576
"""The largest ``max_context_length`` the params model accepts, and so the hard limit on growth."""
+55 -13
View File
@@ -36,6 +36,7 @@ from horde.classes.kobold.chat_request import (
)
from horde.classes.kobold.processing_generation import TextProcessingGeneration
from horde.classes.kobold.worker import text_array_column_type
from horde.config import Config
from horde.flask import SQLITE_MODE, db
from horde.horde_redis import horde_redis as hr
from horde.logger import logger
@@ -70,6 +71,36 @@ SQLITE_CHAT_IMAGE_PART_PATTERN = '%"type": "image_url"%'
escaped in that serialisation, so only a part's ``type`` key can match it."""
def compute_free_output_allowance(
*,
competing_jobs_ahead: int,
eligible_threads: int,
ceiling: int,
floor: int,
slope: float,
) -> int:
"""Return how many tokens a text request may ask for without holding its kudos upfront.
The allowance falls linearly from ``ceiling`` with the competing jobs waiting per eligible thread, so it measures
the load on the workers that could serve this request rather than on the whole horde.
Args:
competing_jobs_ahead: The waiting generations of the queued requests the request's eligible workers could take.
eligible_threads: The advertised threads of the workers eligible for the request.
ceiling: The allowance when no competing job waits.
floor: The smallest allowance, and the allowance when no thread is eligible.
slope: The tokens the allowance loses per competing job waiting for each eligible thread.
Returns:
``ceiling - slope * competing_jobs_ahead / eligible_threads``, rounded and clamped to ``floor``..``ceiling``.
"""
if eligible_threads <= 0:
return floor
jobs_per_thread = competing_jobs_ahead / eligible_threads
allowance = round(ceiling - slope * jobs_per_thread)
return max(floor, min(ceiling, allowance))
class TextWaitingPrompt(WaitingPrompt):
__mapper_args__ = {
"polymorphic_identity": "text",
@@ -518,22 +549,33 @@ class TextWaitingPrompt(WaitingPrompt):
style_reward=style_reward,
)
def require_upfront_kudos(self, counted_totals, total_threads):
"""Returns A tuple
First entry in the tuple is True if this wp requires that the user already has the required kudos to fulfil it
else is False
Second entry in the tuple is the max tokens that can be used without upfront kudos
Third entry in the tuple is whether the upfront kudos requirement prevents downgrading to resolve this.
def require_upfront_kudos(self, *, competing_jobs_ahead: int, eligible_threads: int) -> tuple[bool, int, bool]:
"""Return whether this request must hold its kudos upfront, its free output allowance, and whether downgrading
is barred.
The allowance comes from
[`compute_free_output_allowance`][horde.classes.kobold.waiting_prompt.compute_free_output_allowance] under the
ceiling, floor and slope in [`Config`][horde.config.Config]. A request that disallows slow workers always holds
its kudos upfront.
Args:
competing_jobs_ahead: The waiting generations of the queued requests the request's eligible workers could
take.
eligible_threads: The advertised threads of the workers eligible for the request at its ``max_length``.
Returns:
Whether the requester must already have the kudos for the request, the most tokens it may ask for without
them, and whether the requirement forbids downgrading the request to the allowance.
"""
queue = counted_totals["queued_text_requests"]
max_tokens = 512 + (total_threads * 5) - round(queue * 0.9)
# logger.debug([queue,max_tokens])
max_tokens = compute_free_output_allowance(
competing_jobs_ahead=competing_jobs_ahead,
eligible_threads=eligible_threads,
ceiling=Config.text_free_output_ceiling,
floor=Config.text_free_output_floor,
slope=Config.text_free_output_slope,
)
if not self.slow_workers:
return (True, max_tokens, False)
if max_tokens < 256:
max_tokens = 256
if max_tokens > 512:
max_tokens = 512
if self.max_length > max_tokens:
return (True, max_tokens, False)
if os.getenv("HORDE_UPFRONT_KUDOS_ON_WORKERLIST", "0") == "1" and len(self.workers) > 0:
+11
View File
@@ -36,3 +36,14 @@ class Config:
smaller. Off by default: Cloudflare may already compress the download at the edge, and R2 may transcode an object
stored gzipped, so which of the two saves transfer is measured on a deployment before turning it on. Off, the body
is stored as plain JSON."""
text_free_output_ceiling: int = env.int("HORDE_TEXT_FREE_OUTPUT_CEILING", default=4096)
"""The most tokens a text or chat request may ask for without holding its kudos upfront, reached when no
competing job waits for the workers eligible for it. 4096 is what an idle fleet serves free: current instruct and
reasoning models routinely need more than 512 tokens for one answer."""
text_free_output_floor: int = env.int("HORDE_TEXT_FREE_OUTPUT_FLOOR", default=512)
"""The fewest tokens a text or chat request may ask for without holding its kudos upfront, however busy its
eligible workers are and when none is eligible. 512 keeps the chat default ``max_tokens`` free under any load."""
text_free_output_slope: float = env.float("HORDE_TEXT_FREE_OUTPUT_SLOPE", default=1792.0)
"""The tokens the free allowance loses per competing job waiting for each eligible thread. The default is half
of the 3584 tokens between the default ceiling and floor, so one competing job per eligible thread halves the
headroom above the floor and two reach the floor."""
+53 -2
View File
@@ -1879,7 +1879,7 @@ def _get_competing_wp_queue_stats(wp: WaitingPrompt, served_model_names: frozens
things_ahead_in_queue = 0.0
n_ahead_in_queue = 0
for queued_request in cached_queue.requests[:own_index]:
if queued_request.model_names and queued_request.model_names.isdisjoint(served_model_names):
if not _queued_request_competes(queued_request, served_model_names):
continue
competing_ahead += 1
things_ahead_in_queue += queued_request.queued_things
@@ -1890,6 +1890,31 @@ def _get_competing_wp_queue_stats(wp: WaitingPrompt, served_model_names: frozens
return (competing_ahead, round(things_ahead_in_queue, 2), n_ahead_in_queue)
def _queued_request_competes(queued_request: QueuedRequest, served_model_names: frozenset[str]) -> bool:
"""Return whether a queued request accepts any model or one of ``served_model_names``."""
return not queued_request.model_names or not queued_request.model_names.isdisjoint(served_model_names)
def count_competing_queued_jobs(wp_type: str, served_model_names: frozenset[str]) -> int | None:
"""Return the waiting generations of every queued request that workers serving ``served_model_names`` could take.
A request being created is not yet in the stored queue, so every competing request counts as ahead of it. Like
``_get_competing_wp_queue_stats``, this reads only the in-process copy of the stored queue.
Args:
wp_type: The request type whose queue is read.
served_model_names: The models at least one worker eligible for the new request serves.
Returns:
The summed ``n`` of the competing queued requests; None when no queue is stored.
"""
_refresh_wp_queue_caches(wp_type)
cached_queue = _wp_queue_cache.get(wp_type)
if cached_queue is None:
return None
return sum(queued_request.n for queued_request in cached_queue.requests if _queued_request_competes(queued_request, served_model_names))
def get_wp_queue_stats(wp, *, served_model_names: frozenset[str] | None = None):
"""Return the queue position of ``wp``, the work queued up to and including it, and the generations in that work.
@@ -2465,7 +2490,11 @@ def _parse_declaration_generation(raw_generation: bytes | str | None) -> int:
return int(raw_generation)
def check_new_request_has_valid_workers(waiting_prompt: WaitingPrompt) -> bool:
def check_new_request_has_valid_workers(
waiting_prompt: WaitingPrompt,
*,
creation_availability: RequestWorkerAvailability | None = None,
) -> bool:
"""Return whether a just-created request has an eligible worker, seeding its availability when it does.
A feasible verdict is cached as the full availability, so the first status poll does not repeat the
@@ -2475,11 +2504,23 @@ def check_new_request_has_valid_workers(waiting_prompt: WaitingPrompt) -> bool:
Args:
waiting_prompt: Request created by the current API call, with no generation started.
creation_availability: The availability
[`get_worker_availability_for_request`][horde.database.functions.get_worker_availability_for_request]
measured for the request in the same API call, which already cached it. When given, the verdict is read
from it instead of repeating the eligible-worker query. Pass None when the request changed since, for
example by a downgrade, since its eligible workers change with it.
Returns:
True when at least one recently active worker passes all known dispatch gates.
"""
if creation_availability is not None:
if creation_availability.is_possible:
return True
# The creation-time measurement cached the full infeasible availability. Only its validity flag stays cached,
# the same as for an infeasible verdict computed here.
hr.horde_r_delete(f"wp_availability_{waiting_prompt.id}")
return False
declaration_generation = _get_declaration_generation(waiting_prompt)
with logfire.span("horde.db.eligible_workers", wp_id=str(waiting_prompt.id)):
eligible_workers = list(_iter_eligible_workers_for_request(waiting_prompt))
@@ -2640,6 +2681,16 @@ def _cache_worker_availability(
)
def discard_cached_worker_availability(waiting_prompt: WaitingPrompt) -> None:
"""Delete a request's cached availability and validity verdict, for a request whose eligibility inputs changed.
Args:
waiting_prompt: Request whose cached entries are deleted.
"""
hr.horde_r_delete(f"wp_availability_{waiting_prompt.id}")
hr.horde_r_delete(f"wp_validity_{waiting_prompt.id}")
def _get_active_worker_dispatches(
waiting_prompt: WaitingPrompt,
eligible_worker_ids: list[str],
+3 -3
View File
@@ -17,7 +17,7 @@ inside those objects and the OpenAI SDK replays its accumulated reply with keys
unknown keys, as OpenAI does.
[`ChatCompletionRequest`][horde.oai.schema.ChatCompletionRequest] then validates what remains with ``extra="forbid"``.
Values outside the service contract (``n`` other than 1, a token limit outside 16 to 4096, more than four stop
Values outside the service contract (``n`` other than 1, a token limit outside 16 to 16384, more than four stop
sequences, a sampling parameter outside OpenAI's range) raise the error type
[`UNSUPPORTED_PARAMETER_ERROR_TYPE`][horde.oai.schema.UNSUPPORTED_PARAMETER_ERROR_TYPE] so the front reports them as
``UnsupportedChatParameter``; contradictory values raise
@@ -734,7 +734,7 @@ class ChatCompletionRequest(BaseModel):
"""Options for a streamed answer; only allowed with ``stream: true``."""
max_tokens: StrictInt | None = None
"""The most tokens to generate, 16 to 4096; OpenAI accepts any value from 1."""
"""The most tokens to generate, 16 to 16384; OpenAI accepts any value from 1."""
max_completion_tokens: StrictInt | None = None
"""The newer key for ``max_tokens``; both must agree when both are set."""
@@ -874,7 +874,7 @@ class ChatCompletionRequest(BaseModel):
Horde floor, which OpenAI does not have.
Raises:
PydanticCustomError: The limit is outside 16 to 4096.
PydanticCustomError: The limit is outside 16 to 16384.
"""
if token_limit is None or info.field_name is None:
return token_limit
+58 -1
View File
@@ -18,6 +18,7 @@ import pytest
from horde import chat_signal
from horde.chat_contract import KudosCapExcess
from horde.chat_signal import chat_signal_channel
from horde.classes.kobold.request_fit import MAX_LENGTH_LIMIT
from tests.integration._fake_chat_worker import (
FakeChatWorker,
build_chat_submit_body,
@@ -816,7 +817,7 @@ def test_an_unsupported_chat_parameter_is_refused_with_its_return_code(
[
({"temperature": 9}, "temperature"),
({"max_tokens": 8}, "max_length"),
({"max_tokens": 5000}, "max_length"),
({"max_tokens": MAX_LENGTH_LIMIT + 1}, "max_length"),
],
)
def test_a_chat_parameter_outside_the_text_range_is_refused_unclamped(
@@ -1058,6 +1059,62 @@ def test_a_dry_run_over_max_kudos_is_refused_as_its_submission_would_be(app, cli
assert _count_chat_requests(app, requester.id) == 0
ANONYMOUS_HEADERS = {"apikey": "0000000000", "Client-Agent": "aihorde_ci_client:1:test"}
"""The anonymous key, which holds no kudos, so it is refused any request above the free output allowance."""
def _long_output_chat_body(max_tokens: int) -> dict[str, Any]:
"""Return a chat body asking for ``max_tokens`` under a context large enough for it."""
return {
"model": CHAT_MODEL,
"messages": CHAT_MESSAGES,
"max_tokens": max_tokens,
"horde": {"max_context_length": 8192},
}
@pytest.fixture
def idle_long_output_worker(app, client, request_headers: dict[str, str]) -> Iterator[FakeChatWorker]:
"""An online chat worker with no queued work that serves outputs past the free output ceiling.
It is taken offline afterwards, since its limits are larger than the ones other tests expect of the online pool.
"""
worker = FakeChatWorker(
client=client,
headers=request_headers,
name=f"CICD Long Reply Scribe {uuid.uuid4().hex[:8]}",
models=[CHAT_MODEL],
pop_options={"chat_max_length": 8192, "chat_max_context_length": 8192},
)
worker.pop()
yield worker
_take_worker_offline(app, worker.name)
def test_an_anonymous_chat_request_within_the_ceiling_is_free_with_an_idle_eligible_worker(
client,
idle_long_output_worker: FakeChatWorker,
) -> None:
response = _post_chat(client, ANONYMOUS_HEADERS, _long_output_chat_body(2048))
assert response.status_code == 202, response.get_data(as_text=True)
_cancel(client, ANONYMOUS_HEADERS, "chat", response.get_json()["id"])
def test_an_anonymous_chat_request_above_the_ceiling_is_refused_stating_the_allowance(
client,
idle_long_output_worker: FakeChatWorker,
) -> None:
from horde.config import Config
response = _post_chat(client, ANONYMOUS_HEADERS, _long_output_chat_body(Config.text_free_output_ceiling + 1))
assert response.status_code == 403, response.get_data(as_text=True)
refusal = response.get_json()
assert refusal["rc"] == "KudosUpfront"
assert f"up to {Config.text_free_output_ceiling} tokens" in refusal["message"]
def test_a_repeated_operation_key_is_answered_while_admission_is_off(client, make_api_user, monkeypatch) -> None:
from horde.config import Config
+2 -1
View File
@@ -20,6 +20,7 @@ import pytest
from flask.testing import FlaskClient
from werkzeug.test import TestResponse
from horde.classes.kobold.request_fit import MAX_LENGTH_LIMIT
from tests.fixture_types import MakeApiUser
TEXT_MODELS = ["elinas/chronos-70b-v2"]
@@ -446,7 +447,7 @@ class TestTextStyleContractRejections:
assert response.status_code == 400, response.get_data(as_text=True)
@pytest.mark.parametrize("ceiling", [15, 4097])
@pytest.mark.parametrize("ceiling", [15, MAX_LENGTH_LIMIT + 1])
def test_a_ceiling_outside_the_parameter_range_is_rejected(
self,
client,
+2 -2
View File
@@ -691,8 +691,8 @@ the anonymous key and nothing else differs on the wire
(`docs/decisions/0035-keep-anonymous-access-on-the-chat-front.md`), so the
expected outcome stays the scenario's. The Horde's side differs: the anonymous
user holds no kudos and ranks last, so its queue wait is recorded apart
(`[queue_wait_seconds] <persona> anonymous`); a request above 256 tokens, the
floor of the Horde's upfront-kudos threshold (`require_upfront_kudos`), may get
(`[queue_wait_seconds] <persona> anonymous`); a request above 512 tokens, the
default floor of the Horde's free output allowance (`require_upfront_kudos`), may get
403 `KudosUpfront` (benign), while one at or below it never may, so that refusal
is a contract violation there; and all anonymous traffic shares one concurrency
limit, whose 429 is benign like any rate limit. `anonymous_plain` stays as an
+6 -5
View File
@@ -1458,11 +1458,12 @@ OVERSIZED_BODY_BYTES: int = MAX_CHAT_BODY_BYTES + 1
ANONYMOUS_SHARE_GROUPS: frozenset[ScenarioGroup] = frozenset({ScenarioGroup.ORDINARY, ScenarioGroup.LITE})
"""The groups whose scenarios a run also sends anonymously, at ``--chat-mix-anonymous-share``."""
UPFRONT_KUDOS_FREE_TOKENS: int = 256
"""The most tokens a request may ask for without the Horde ever asking upfront kudos: the floor of its demand-based
threshold (``TextWaitingPrompt.require_upfront_kudos``, between 256 and 512 tokens for requests that allow slow
workers, as chat requests do by default). Above it an anonymous request, which holds no kudos, may get 403
``KudosUpfront``; at or below it the refusal would break the contract."""
UPFRONT_KUDOS_FREE_TOKENS: int = 512
"""The most tokens a request may ask for without the Horde ever asking upfront kudos: the default floor of its free
output allowance (``TextWaitingPrompt.require_upfront_kudos``, between 512 and 4096 tokens by default for requests that
allow slow workers, as chat requests do by default, depending on the load on the request's eligible workers). Above it
an anonymous request, which holds no kudos, may get 403 ``KudosUpfront``; at or below it the refusal would break the
contract."""
NATIVE_DEFAULT_MAX_TOKENS: int = 512
"""The ``max_tokens`` a chat request gets when it sends none."""
+3 -3
View File
@@ -11,7 +11,7 @@ import base64
from typing import Any
import pytest
from horde_sdk.generation_parameters.text.chat_limits import MAX_CHAT_SCHEMA_NESTING
from horde_sdk.generation_parameters.text.chat_limits import MAX_CHAT_MAX_TOKENS, MAX_CHAT_SCHEMA_NESTING
from horde.chat_contract import (
MAX_CHAT_IMAGE_BYTES,
@@ -132,8 +132,8 @@ async def test_default_no_op_is_accepted_and_not_forwarded(start_front: StartFro
({"horde": {"workers": ["w"]}}, "horde.workers"),
({"n": 2}, "n"),
({"max_tokens": 8}, "max_tokens"),
({"max_tokens": 5000}, "max_tokens"),
({"max_completion_tokens": 5000, "max_tokens": None}, "max_completion_tokens"),
({"max_tokens": MAX_CHAT_MAX_TOKENS + 1}, "max_tokens"),
({"max_completion_tokens": MAX_CHAT_MAX_TOKENS + 1, "max_tokens": None}, "max_completion_tokens"),
({"stop": ["a", "b", "c", "d", "e"]}, "stop"),
],
)
+98 -30
View File
@@ -18,7 +18,8 @@ from types import SimpleNamespace
import pytest
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt, compute_free_output_allowance
from horde.config import Config
def _wp(slow_workers=True, max_length=80, max_context_length=2048, workers=(), models=()):
@@ -31,50 +32,117 @@ def _wp(slow_workers=True, max_length=80, max_context_length=2048, workers=(), m
)
FREE_OUTPUT_CEILING = 4096
"""The default ceiling of the free output allowance, which the curve tests pass explicitly."""
FREE_OUTPUT_FLOOR = 512
"""The default floor of the free output allowance."""
FREE_OUTPUT_SLOPE = 1792.0
"""The default slope: one competing job per eligible thread halves the headroom above the floor."""
def _allowance(*, competing_jobs_ahead: int, eligible_threads: int) -> int:
return compute_free_output_allowance(
competing_jobs_ahead=competing_jobs_ahead,
eligible_threads=eligible_threads,
ceiling=FREE_OUTPUT_CEILING,
floor=FREE_OUTPUT_FLOOR,
slope=FREE_OUTPUT_SLOPE,
)
class TestComputeFreeOutputAllowance:
def test_idle_eligible_workers_give_the_ceiling(self):
assert _allowance(competing_jobs_ahead=0, eligible_threads=4) == FREE_OUTPUT_CEILING
def test_one_competing_job_per_thread_halves_the_headroom(self):
assert _allowance(competing_jobs_ahead=4, eligible_threads=4) == 2304
def test_load_is_measured_per_eligible_thread(self):
"""Six jobs ahead weigh on four threads as three jobs ahead do on two."""
assert _allowance(competing_jobs_ahead=6, eligible_threads=4) == _allowance(competing_jobs_ahead=3, eligible_threads=2)
assert _allowance(competing_jobs_ahead=6, eligible_threads=4) == 1408
assert _allowance(competing_jobs_ahead=1, eligible_threads=4) == 3648
def test_saturated_eligible_workers_give_the_floor(self):
assert _allowance(competing_jobs_ahead=2, eligible_threads=1) == FREE_OUTPUT_FLOOR
assert _allowance(competing_jobs_ahead=500, eligible_threads=10) == FREE_OUTPUT_FLOOR
def test_no_eligible_thread_gives_the_floor(self):
assert _allowance(competing_jobs_ahead=0, eligible_threads=0) == FREE_OUTPUT_FLOOR
class TestRequireUpfrontKudos:
def test_no_slow_workers_requires_upfront_with_unclamped_budget(self):
# When slow workers are disallowed the clamp is skipped, so the returned
# token budget is the raw formula (512 + threads*5 - round(queue*0.9)).
result = TextWaitingPrompt.require_upfront_kudos(
_wp(slow_workers=False),
{"queued_text_requests": 0},
100,
)
assert result == (True, 1012, False)
@pytest.fixture(autouse=True)
def default_allowance_settings(self, monkeypatch):
monkeypatch.setattr(Config, "text_free_output_ceiling", FREE_OUTPUT_CEILING)
monkeypatch.setattr(Config, "text_free_output_floor", FREE_OUTPUT_FLOOR)
monkeypatch.setattr(Config, "text_free_output_slope", FREE_OUTPUT_SLOPE)
monkeypatch.delenv("HORDE_UPFRONT_KUDOS_ON_WORKERLIST", raising=False)
def test_within_budget_no_upfront(self):
def test_idle_fleet_serves_up_to_the_ceiling_free(self):
result = TextWaitingPrompt.require_upfront_kudos(
_wp(slow_workers=True, max_length=400),
{"queued_text_requests": 0},
0,
_wp(max_length=FREE_OUTPUT_CEILING),
competing_jobs_ahead=0,
eligible_threads=1,
)
assert result == (False, 512, False)
assert result == (False, FREE_OUTPUT_CEILING, False)
def test_over_budget_requires_upfront(self):
def test_over_the_allowance_requires_upfront(self):
result = TextWaitingPrompt.require_upfront_kudos(
_wp(slow_workers=True, max_length=600),
{"queued_text_requests": 0},
0,
_wp(max_length=FREE_OUTPUT_CEILING + 1),
competing_jobs_ahead=0,
eligible_threads=1,
)
assert result == (True, 512, False)
assert result == (True, FREE_OUTPUT_CEILING, False)
def test_queue_pressure_clamps_budget_to_floor(self):
# queue=300 -> 512 - 270 = 242, clamped up to the 256 floor; 300 > 256.
result = TextWaitingPrompt.require_upfront_kudos(
_wp(slow_workers=True, max_length=300),
{"queued_text_requests": 300},
0,
def test_saturated_fleet_serves_only_the_floor_free(self):
within_floor = TextWaitingPrompt.require_upfront_kudos(
_wp(max_length=FREE_OUTPUT_FLOOR),
competing_jobs_ahead=40,
eligible_threads=4,
)
assert result == (True, 256, False)
over_floor = TextWaitingPrompt.require_upfront_kudos(
_wp(max_length=FREE_OUTPUT_FLOOR + 1),
competing_jobs_ahead=40,
eligible_threads=4,
)
assert within_floor == (False, FREE_OUTPUT_FLOOR, False)
assert over_floor == (True, FREE_OUTPUT_FLOOR, False)
def test_no_eligible_thread_serves_only_the_floor_free(self):
result = TextWaitingPrompt.require_upfront_kudos(
_wp(max_length=FREE_OUTPUT_FLOOR + 1),
competing_jobs_ahead=0,
eligible_threads=0,
)
assert result == (True, FREE_OUTPUT_FLOOR, False)
def test_no_slow_workers_always_requires_upfront(self):
result = TextWaitingPrompt.require_upfront_kudos(
_wp(slow_workers=False, max_length=80),
competing_jobs_ahead=0,
eligible_threads=1,
)
assert result == (True, FREE_OUTPUT_CEILING, False)
def test_configured_ceiling_and_floor_apply(self, monkeypatch):
monkeypatch.setattr(Config, "text_free_output_ceiling", 1024)
monkeypatch.setattr(Config, "text_free_output_floor", 256)
idle = TextWaitingPrompt.require_upfront_kudos(_wp(max_length=1024), competing_jobs_ahead=0, eligible_threads=1)
busy = TextWaitingPrompt.require_upfront_kudos(_wp(max_length=1024), competing_jobs_ahead=100, eligible_threads=1)
assert idle == (False, 1024, False)
assert busy == (True, 256, False)
def test_workerlist_env_forces_upfront_and_blocks_downgrade(self, monkeypatch):
monkeypatch.setenv("HORDE_UPFRONT_KUDOS_ON_WORKERLIST", "1")
result = TextWaitingPrompt.require_upfront_kudos(
_wp(slow_workers=True, max_length=200, workers=["w"]),
{"queued_text_requests": 0},
0,
competing_jobs_ahead=0,
eligible_threads=1,
)
assert result == (True, 512, True)
assert result == (True, FREE_OUTPUT_CEILING, True)
class TestKudos:
+8 -5
View File
@@ -9,6 +9,7 @@ smoke checker's contract and environment buckets."""
from __future__ import annotations
import csv
import dataclasses
import json
import os
import random
@@ -969,11 +970,13 @@ class TestAnonymousVariant:
assert anonymous.kudos_upfront_benign is False
def test_kudos_upfront_is_benign_only_above_the_upfront_free_size(self) -> None:
at_limit = [
builder(CONTEXT) for builder in SCENARIO_BUILDERS[ScenarioGroup.ORDINARY] if builder.__name__ == "max_tokens_at_worker_limit"
][0]
assert at_limit.body["max_tokens"] > UPFRONT_KUDOS_FREE_TOKENS
assert as_anonymous(at_limit).kudos_upfront_benign is True
at_limit_builder = next(
builder for builder in SCENARIO_BUILDERS[ScenarioGroup.ORDINARY] if builder.__name__ == "max_tokens_at_worker_limit"
)
at_free_size = at_limit_builder(dataclasses.replace(CONTEXT, worker_max_length=UPFRONT_KUDOS_FREE_TOKENS))
above_free_size = at_limit_builder(dataclasses.replace(CONTEXT, worker_max_length=UPFRONT_KUDOS_FREE_TOKENS + 1))
assert as_anonymous(at_free_size).kudos_upfront_benign is False
assert as_anonymous(above_free_size).kudos_upfront_benign is True
def test_kudos_upfront_on_a_small_anonymous_request_is_a_violation(self) -> None:
envelope = {"error": {"message": "upfront", "type": "permission_error", "param": None, "code": "KudosUpfront"}}
@@ -956,3 +956,96 @@ def test_a_request_without_eligible_threads_reads_impossible_with_the_horde_wide
assert status["eligible_worker_threads"] == 0
assert status["queue_position"] == 2
assert status["wait_time"] == round((512 + 80) / TEXT_REQUEST_AVG)
def _refresh_stored_queue(fake_redis: Any, monkeypatch: pytest.MonkeyPatch) -> None:
"""Store the prioritized queues as the primary does and clear the in-process copies, so the next read loads them."""
threads.store_prioritized_wp_queue()
monkeypatch.setitem(functions._wp_queue_cache, "text", None)
monkeypatch.setitem(functions._wp_queue_positions_time, "text", 0.0)
monkeypatch.setitem(functions._wp_queue_positions_cache, "text", {})
def test_a_new_request_counts_every_queued_generation_its_eligible_workers_could_take(
db_session: Any,
fake_redis: Any,
make_user: Any,
make_user_role: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Every competing queued request counts, with its waiting generations, since the new request is not queued yet."""
user = make_user()
for _ in range(2):
_queue_text_request(user, ["shared-model"], max_length=200)
multi_generation_request = _queue_text_request(user, ["shared-model", "other-model"], max_length=200)
multi_generation_request.n = 3
_queue_text_request(user, [], max_length=200)
for _ in range(4):
_queue_text_request(user, ["busy-model"], max_length=200)
db.session.commit()
_refresh_stored_queue(fake_redis, monkeypatch)
competing_jobs = functions.count_competing_queued_jobs("text", frozenset({"shared-model"}))
assert competing_jobs == 2 + 3 + 1
def test_a_new_request_has_no_competing_count_without_a_stored_queue(
db_session: Any,
fake_redis: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setitem(functions._wp_queue_cache, "text", None)
monkeypatch.setitem(functions._wp_queue_positions_time, "text", 0.0)
assert functions.count_competing_queued_jobs("text", frozenset({"shared-model"})) is None
def test_the_check_after_activation_reuses_the_availability_measured_at_creation(
db_session: Any,
fake_redis: Any,
make_user: Any,
make_user_role: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A feasible creation-time measurement answers the check and stays cached for the first status poll."""
monkeypatch.setattr("horde.classes.kobold.worker.model_reference.get_text_model_multiplier", lambda _model_name: 20)
user = make_user()
make_user_role(user, UserRoleTypes.TRUSTED, value=True)
request = _make_text_request(user, "measured-model")
_make_text_worker(user, "measured-model", speed=10)
creation_availability = functions.get_worker_availability_for_request(request)
def fail_worker_query(*_args: Any, **_kwargs: Any) -> None:
raise AssertionError("the check after activation repeated the eligible-worker query")
monkeypatch.setattr(functions, "_iter_eligible_workers_for_request", fail_worker_query)
assert functions.check_new_request_has_valid_workers(request, creation_availability=creation_availability) is True
assert fake_redis.horde_r_get(f"wp_availability_{request.id}") is not None
assert functions.get_worker_availability_for_request(request).worker_count == 1
def test_an_infeasible_creation_measurement_leaves_only_the_validity_flag_cached(
db_session: Any,
fake_redis: Any,
make_user: Any,
make_user_role: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The first status poll of a request no worker could take at creation measures its availability afresh."""
user = make_user()
make_user_role(user, UserRoleTypes.TRUSTED, value=True)
request = _make_text_request(user, "unserved-model")
creation_availability = functions.get_worker_availability_for_request(request)
def fail_worker_query(*_args: Any, **_kwargs: Any) -> None:
raise AssertionError("the check after activation repeated the eligible-worker query")
with monkeypatch.context() as query_guard:
query_guard.setattr(functions, "_iter_eligible_workers_for_request", fail_worker_query)
has_valid_workers = functions.check_new_request_has_valid_workers(request, creation_availability=creation_availability)
assert has_valid_workers is False
assert fake_redis.horde_r_get(f"wp_availability_{request.id}") is None
assert int(fake_redis.horde_r_get(f"wp_validity_{request.id}")) == 0