mirror of
https://github.com/Haidra-Org/AI-Horde.git
synced 2026-11-06 05:17:31 +00:00
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:
19 files changed
+497
-78
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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. "
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user