feat(chat): send reasoning_effort medium to declaring workers when the client omits it

OpenAI's default `reasoning_effort` is `medium`. koboldcpp maps the value to a thinking budget as a fraction of `max_length` and treats an absent value as unlimited, so a request with a small `max_tokens` spent the whole budget on reasoning and returned an empty reply. Reasoning cannot be resumed through the OpenAI API, so a reply cut off while thinking is lost, and `max_tokens` has to cover the reasoning and the answer in one request.

- `DEFAULT_CHAT_REASONING_EFFORT` in `horde/chat_contract.py` is `medium`
- `apply_worker_chat_defaults` adds it to one job's payload copy in `TextWaitingPrompt.get_pop_payload`, only for a worker that declares the `reasoning_effort` feature and only when the client sent no value. An omitted value does not require the feature, so routing, the stored request, the operation fingerprint and the kudos estimate do not change. An explicit value, `none` included, is passed unchanged
- Unit tests cover the default, the untouched explicit values and non-declaring workers. Integration tests pop a declaring and a non-declaring worker
- ADR 0033 and 0036, the pop payload and request field descriptions, and the chat request, serve-chat-jobs and send-chat-completions docs state the behaviour
- The send-chat-completions how-to, the chat request reference and explanation, ADR 0033 and the native and front request field descriptions publish the budget policy: reasoning is generated within `max_tokens`, an omitted effort gives a declaring worker a budget of about half on koboldcpp, the budget applies per thinking block and does not guarantee an answer, and a reply cut off while thinking cannot be continued. The `include_reasoning` swagger field states its true default
This commit is contained in:
tazlin committed 2026-11-02 09:34:51 -05:00
1 parent 993e45c6cf
commit 7d13ce4508
14 files changed
+214 -28

No files matched your search

@@ -50,10 +50,19 @@ into the job payload, with the client's value where it sent one:
| `min_p` | 0.0 |
The first four are OpenAI's defaults. OpenAI does not have a key for `rep_pen`, `top_k` or `min_p`, so they are set
to their neutral values. `stop_sequence`, `seed` and `reasoning_effort` are written only when the client sent them. KoboldAI samplers
to their neutral values. `stop_sequence` and `seed` are written only when the client sent them. KoboldAI samplers
outside the table, such as `top_a`, `typical`, `tfs` and `rep_pen_range`, are not written, so the worker's own
defaults apply to them. The table applies to chat jobs only; native text jobs do not use it.
An omitted `reasoning_effort` is sent as `medium`, OpenAI's default (`DEFAULT_CHAT_REASONING_EFFORT` in
`horde/chat_contract.py`), to a worker that declares the `reasoning_effort` feature. koboldcpp gives an absent value
an unlimited thinking budget, so a small `max_tokens` would be spent on reasoning alone and the reply would be empty.
The default is added when the job is popped (`apply_worker_chat_defaults`, called from
`TextWaitingPrompt.get_pop_payload`), since only then is the worker known. It does not add a requirement, so any chat
worker may serve the request, and a worker that does not declare the feature receives no `reasoning_effort`. The
stored request, its required features and its kudos estimate do not change. An explicit value, `none` included,
requires a declaring worker and is passed unchanged.
The front forwards a `developer` message to the native chat route unchanged. The native route stores it as `system`
(`serialize_messages`), so a worker only meets roles every chat template knows. Since every request is normalised
this way, the chat features do not include one for preserving the `developer` role.
@@ -61,7 +70,12 @@ this way, the chat features do not include one for preserving the `developer` ro
### Consequences
- Good: An omitted field in the table has the same value on every worker.
- Good: A request that omits `reasoning_effort` keeps part of a small `max_tokens` for the reply on a worker that
applies the value.
- Good: Clients that send `developer` are served by templates that predate the role.
- Bad: The `reasoning_effort` default lowers the chance of an empty reply and does not guarantee an answer. On
koboldcpp the thinking budget applies per thinking block, so a model that opens a second block can still spend the
whole `max_tokens`.
- Bad: Rewriting `developer` to `system` loses any precedence a model gives the two roles.
- Bad: An operator's tuned defaults for the samplers in the table do not apply to chat jobs.
- Bad: Samplers outside the table take each worker's defaults, so the same request can still sample differently on
@@ -86,7 +100,7 @@ this way, the chat features do not include one for preserving the `developer` ro
## Confirmation
`tests/unit/test_chat_request.py` (`TestChatJobPayload`, `TestMessages`) fixes the payload defaults and the stored
role. `tests/unit/oai/test_chat_front.py` fixes the front's forwarding of `developer`.
`tests/unit/test_chat_request.py` (`TestChatJobPayload`, `TestWorkerChatDefaults`, `TestMessages`) fixes the payload
defaults, the `reasoning_effort` default and the stored role. `tests/unit/oai/test_chat_front.py` fixes the front's forwarding of `developer`.
`docs/reference/chat_requests.md` (Request body, Chat job pop response) and `docs/reference/chat_front.md` (Message
roles) record the public behaviour.
@@ -57,8 +57,10 @@ Three declaration kinds are defined once in `horde_sdk.generation_parameters.tex
applied as sent.
`reasoning_effort` accepts OpenAI's values `none`, `minimal`, `low`, `medium`, `high` and `xhigh`, and is forwarded
unchanged. The server does not set a default. A request that sets it, `none` included, needs a worker that declares the
`reasoning_effort` capability, since a backend that ignores the key reasons anyway.
unchanged. A request that sets it, `none` included, needs a worker that declares the `reasoning_effort` capability,
since a backend that ignores the key reasons anyway. A request that omits it does not need the capability. A worker
that declares the capability receives `medium`, OpenAI's default, as [ADR
0033](0033-fill-openai-sampling-defaults-and-map-the-developer-role.md) describes.
Each request carries a conformance mode, `horde.conformance`, which is `strict` by default. The server derives the
request's requirements once at submit (`required_chat_features` in `horde/classes/kobold/chat_request.py`), stores
+9
View File
@@ -331,6 +331,15 @@ completion tokens, so its characters are counted with the content and tool calls
is the worker's speed sample. The worker's `reasoning_tokens` count is shown and never charged, as with every other
reported count ([Reasoning](../reference/chat_requests.md#reasoning)).
Reasoning and the answer share `max_tokens`, and the OpenAI API has no way to resume reasoning. A reply cut off while
thinking cannot be continued, and in a follow-up request the earlier reasoning is a previous turn. The thinking and the
answer therefore have to fit in one request. koboldcpp gives an absent `reasoning_effort` an unlimited thinking budget,
so a small `max_tokens` could be spent on reasoning alone. For that reason a worker that declares the feature receives
`medium`, OpenAI's default, when the client omits the value
([ADR 0033](../decisions/0033-fill-openai-sampling-defaults-and-map-the-developer-role.md)). On koboldcpp that caps the
thinking at about half of `max_tokens`. The default lowers the chance of an empty reply and does not guarantee an
answer.
### Admission against the online pool
Workers are volunteers, and the models online change minute to minute. By default a request waits for a worker for
+18 -2
View File
@@ -114,10 +114,10 @@ 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 16384 (see [Deliberate deviations](#deliberate-deviations)); equal when both are sent. Default 512 when neither is. A value above the largest length declared by an online worker for the model is lowered to that length and reported in `x_horde.context_adjustments`. Above the free output allowance, 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`. |
| `max_tokens`, `max_completion_tokens` | 16 to 16384 (see [Deliberate deviations](#deliberate-deviations)); equal when both are sent. Default 512 when neither is. The reasoning is generated within this limit, together with the answer (see [Reasoning budget](#reasoning-budget)). A value above the largest length declared by an online worker for the model is lowered to that length and reported in `x_horde.context_adjustments`. Above the free output allowance, 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`. |
| `reasoning_effort` | `none`, `minimal`, `low`, `medium`, `high` or `xhigh`, forwarded unchanged; a worker has to declare the `reasoning_effort` feature for any value, `none` included. When you leave it out, any chat worker may serve the request, and a worker that declares the feature receives `medium`, OpenAI's default, which koboldcpp applies as a thinking budget of about half of `max_tokens`. `none` disables thinking; how much reasoning the other values allow is the worker's mapping. Any other value is 400 `BadRequest` with `param` `reasoning_effort`. |
| `n` | Only `1`. |
| `tools`, `tool_choice`, `parallel_tool_calls`, `response_format` | Forwarded unchanged to a worker that declares the feature; see [Tools](#tools-and-tool_choice) and [Structured output](#structured-output). |
| `stream`, `stream_options` | Applied by the front; `stream_options` needs `"stream": true`. |
@@ -245,6 +245,22 @@ is for.
Verification: a request to a reasoning model returns `choices[0].message.reasoning_content`; the same request with
`include_reasoning: false` returns a message without a `reasoning_content` key.
### Reasoning budget
The model generates its reasoning within `max_tokens`, together with the answer, and the reasoning is charged like the
answer. Set `max_tokens` large enough for both.
When you leave out `reasoning_effort`, a worker that declares the feature receives `medium`. On koboldcpp that is a
thinking budget of about half of `max_tokens`. When the reasoning reaches it, the thinking is closed and the answer
follows. A worker that does not declare the feature receives no value, and the model reasons without a cap. An
omitted `max_tokens` defaults to 512 in both cases.
A reply with `finish_reason` `length`, an empty `content` and reasoning in `reasoning_content` ran out of tokens while
thinking.
It cannot be continued, because the OpenAI API has no way to resume reasoning. Reasoning sent back inside an assistant
message becomes a previous turn, and `reasoning_content` on a request message is dropped. Send a fresh request with a
larger `max_tokens` or a lower `reasoning_effort`. `none` disables thinking.
## Images
A `user` message may carry `{"type": "image_url", "image_url": {"url": ..., "detail": ...}}` parts. `url` is a
+5 -2
View File
@@ -173,8 +173,11 @@ A chat job's payload differs from a text payload in these ways:
- `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `rep_pen`, `top_k` and `min_p` are always present,
with OpenAI's defaults where the client left them out. Apply them as sent and ignore the backend's local defaults,
so one request means the same thing on every worker.
- `stop_sequence`, `seed` and `reasoning_effort` are present only when the client sent them. `reasoning_effort` is the
client's value unchanged; the horde applies no default, so a job without it leaves reasoning to the backend.
- `stop_sequence` and `seed` are present only when the client sent them.
- `reasoning_effort` is present when you declare the `reasoning_effort` feature. It is the client's value unchanged,
or `medium`, OpenAI's default, when the client did not send one. A worker that does not declare the feature does not
receive the key. The client's `max_length` covers the reasoning and the answer. When the limit cuts off the thinking,
submit the reasoning as `reasoning_content` and leave `content` empty.
- `max_length` and `max_context_length` are validated budgets within the worker's `chat_limits`. Generate at most
`max_length` tokens and render within `max_context_length`.
- `tools`, `tool_choice`, `parallel_tool_calls` and `response_format` are present only when the request applies them
+1 -1
View File
@@ -66,7 +66,7 @@ unknown keys, as OpenAI does: an unknown key inside `stream_options` or `respons
| Keys | Handling |
| --- | --- |
| `model`, `models`, `messages`, `max_tokens`, `max_completion_tokens`, `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `stop`, `seed`, `reasoning_effort`, `n`, `horde` | Validated and forwarded; null counts as omitted. `reasoning_effort` is one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. |
| `model`, `models`, `messages`, `max_tokens`, `max_completion_tokens`, `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `stop`, `seed`, `reasoning_effort`, `n`, `horde` | Validated and forwarded; null counts as omitted. `reasoning_effort` is one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. `max_tokens` covers the reasoning and the answer together. |
| `tools`, `tool_choice`, `parallel_tool_calls`, `response_format` | Validated against the `horde/chat_contract.py` shapes and forwarded verbatim; a `json_schema` response format is forwarded with its `schema` key and needs one. Dropped at their default: empty or null `tools`, `{"type": "text"}`; a null `tool_choice` or `parallel_tool_calls` is not forwarded. |
| `stream`, `stream_options`, `user`, `metadata` | Applied or accepted by the front and not forwarded, except `stream: true` on a request whose deltas are relayed (see [Relayed deltas](#relayed-deltas)). |
| `logprobs`, `top_logprobs`, `store`, `service_tier` | Dropped at their default (`false`, `0`, `false`, `auto` or `default`); any other value is 400. |
+10 -3
View File
@@ -204,11 +204,11 @@ 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 16384, default `512`. Both may be sent only with equal values. A value above the largest `max_length` declared by the online chat workers serving the models is lowered to that value (see [Context adjustments](#context-adjustments)). Above the [free output allowance](#free-output-allowance) the kudos are held upfront |
| `max_tokens`, `max_completion_tokens` | `max_length`, 16 to 16384, default `512`. Both may be sent only with equal values. A value above the largest `max_length` declared by the online chat workers serving the models is lowered to that value (see [Context adjustments](#context-adjustments)). Above the [free output allowance](#free-output-allowance) the kudos are held upfront. The reasoning is generated within this limit, together with the answer (see [Reasoning](#reasoning)) |
| `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 |
| `reasoning_effort` | One of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (`ChatReasoningEffort`); stored and forwarded to the worker unchanged, with no default. The worker has to declare the `reasoning_effort` feature, for `none` as for any other value. Any other value or type returns `400` |
| `reasoning_effort` | One of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (`ChatReasoningEffort`); stored and forwarded to the worker unchanged. The worker has to declare the `reasoning_effort` feature, for `none` as for any other value. When it is unset the request does not need the feature, and a worker that declares it receives `medium`, OpenAI's default (`DEFAULT_CHAT_REASONING_EFFORT`). On koboldcpp `medium` is a thinking budget of about half of `max_length`. A worker that does not declare the feature receives no value, and the model reasons without a cap. The stored params do not change. Any other value or type returns `400` |
| `stop` | `stop_sequence`; a single string becomes a one-element list |
| `n` | Only `1` |
| `stream` | `true` stores the stream hint; see [Streaming progress](#streaming-progress) |
@@ -663,7 +663,8 @@ A text job's pop response does not carry a `chat` key. A chat job's response has
| `messages_url` | Only for a generation assigned the `messages_url` transport: a signed URL, valid for 120 seconds, to the array as in [Messages offload](#messages-offload) |
| `tools`, `tool_choice`, `parallel_tool_calls`, `response_format` | Each only when stored in `chat_extras`, narrowed as below |
| `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `rep_pen`, `top_k`, `min_p` | Always present; the client's value or `1.0`, `1.0`, `0`, `0`, `1.0`, `0`, `0` |
| `stop_sequence`, `seed`, `reasoning_effort` | Only when the client sent them |
| `stop_sequence`, `seed` | Only when the client sent them |
| `reasoning_effort` | Only for a worker that declares the `reasoning_effort` feature: the client's value, or `medium` when the client did not send one |
| `max_length`, `max_context_length` | The validated budgets |
| `n` | `1` |
@@ -795,6 +796,12 @@ stored as reasoning; a longer one returns `400 ChatSubmitInvalid`. `reasoning_to
the status. Like the other reported counts it never changes the charge or reward, since the Horde pays only for the
output it measures.
The reasoning is generated within `max_length`, together with the answer. A reply with `finish_reason` `length`, an
empty `content` and reasoning in `reasoning_content` ran out of tokens while thinking, and it cannot be continued. The OpenAI API
has no way to resume reasoning. Reasoning sent back inside an assistant message is a prior turn, and
`reasoning_content` on a request message is dropped. Send a fresh request with a larger `max_tokens` or a lower
`reasoning_effort` (`none` disables thinking).
### Reply verification
Before the compare-and-set, `verify_chat_reply` checks the reply against the request's stored `chat_extras` (the
+13 -7
View File
@@ -481,8 +481,9 @@ class TextModels(v2.Models):
"reasoning_effort": fields.String(
enum=[reasoning_effort.value for reasoning_effort in ChatReasoningEffort],
description=(
"The client's reasoning_effort, unchanged, only when the client sent one. 'none' disables "
"thinking. Only workers that declare the reasoning_effort feature receive it."
"The client's reasoning_effort, unchanged, or 'medium' (OpenAI's default) when the client "
"did not send one. 'none' disables thinking. Only workers that declare the reasoning_effort "
"feature receive it."
),
),
"max_length": fields.Integer(description="Number of tokens to generate."),
@@ -1307,9 +1308,10 @@ class TextModels(v2.Models):
),
),
"include_reasoning": fields.Boolean(
default=False,
default=True,
description=(
"For the OpenAI front: when true, it returns the model's reasoning as reasoning_content. The "
"For the OpenAI front: the model's reasoning is returned as reasoning_content unless this is "
"false. The "
"native status shows a submitted reasoning_content either way, and the work does not change."
),
),
@@ -1345,7 +1347,8 @@ class TextModels(v2.Models):
"max_tokens": fields.Integer(
description=(
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."
"max_completion_tokens when both are set. The reasoning is generated within this limit, "
"together with the answer."
),
),
"max_completion_tokens": fields.Integer(description="How many tokens to generate; the newer name for max_tokens."),
@@ -1357,8 +1360,11 @@ class TextModels(v2.Models):
enum=[reasoning_effort.value for reasoning_effort in ChatReasoningEffort],
description=(
"How much the model reasons before answering; 'none' disables thinking. Forwarded to the "
"worker unchanged, with no default. A request that sets it, 'none' included, only goes to "
"workers that declare the reasoning_effort feature. Any other value is refused."
"worker unchanged. A request that sets it, 'none' included, only goes to workers that "
"declare the reasoning_effort feature. When it is unset, any chat worker may serve the "
"request, and a worker that declares the feature receives 'medium', OpenAI's default. A "
"reply cut off while thinking cannot be continued, so max_tokens has to cover the reasoning "
"and the answer. A value outside the listed ones is refused."
),
),
"stop": fields.Raw(description="One stop sequence or an array of them."),
+13 -1
View File
@@ -41,7 +41,12 @@ from horde_sdk.generation_parameters.text.chat_limits import (
MAX_CHAT_SCHEMA_NESTING,
chat_schema_exceeds_nesting,
)
from horde_sdk.generation_parameters.text.chat_policy import DEFAULT_CHAT_CONFORMANCE, ChatConformance, ChatServiceGapKind
from horde_sdk.generation_parameters.text.chat_policy import (
DEFAULT_CHAT_CONFORMANCE,
ChatConformance,
ChatReasoningEffort,
ChatServiceGapKind,
)
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, ValidationInfo, field_validator
from pydantic_core import PydanticCustomError
@@ -52,6 +57,7 @@ __all__ = [
"CHAT_REQUEST_CONFLICT_ERROR_TYPE",
"CHAT_REQUEST_CONFLICT_RC",
"DEFAULT_CHAT_MAX_TOKENS",
"DEFAULT_CHAT_REASONING_EFFORT",
"INTERNAL_HORDE_OPTION_KEYS",
"MAX_CHAT_BODY_BYTES",
"MAX_CHAT_IMAGE_BYTES",
@@ -155,6 +161,12 @@ DEFAULT_CHAT_MAX_TOKENS: int = 512
"""``max_length`` for a chat request that sets neither token limit. OpenAI clients usually omit it, and the text
default of 80 tokens is too short for a chat reply."""
DEFAULT_CHAT_REASONING_EFFORT: ChatReasoningEffort = ChatReasoningEffort.MEDIUM
"""The ``reasoning_effort`` a worker that declares the ``reasoning_effort`` feature receives when the client did not
send one. It is OpenAI's default. koboldcpp gives an absent value an unlimited thinking budget, so a small
``max_tokens`` would be spent on reasoning alone and the reply would be empty. The default is added to the job
payload only: it does not restrict which workers serve the request, and the stored request does not change."""
CHAT_REQUEST_CONFLICT_ERROR_TYPE: str = "chat_request_conflict"
"""The pydantic error type raised for two keys whose values contradict each other.
+30 -4
View File
@@ -49,7 +49,7 @@ import gzip
import hashlib
import itertools
import json
from collections.abc import Callable, Iterator, Mapping, Sequence
from collections.abc import Callable, Collection, Iterator, Mapping, Sequence
from typing import Annotated, Any, Literal, NamedTuple, NoReturn
from horde_sdk.generation_parameters.text.chat_limits import (
@@ -96,6 +96,7 @@ from horde.chat_contract import (
CHAT_REQUEST_CONFLICT_ERROR_TYPE,
CHAT_REQUEST_CONFLICT_RC,
DEFAULT_CHAT_MAX_TOKENS,
DEFAULT_CHAT_REASONING_EFFORT,
MAX_CHAT_IMAGE_BYTES,
MAX_CHAT_IMAGE_COUNT,
MAX_CHAT_IMAGE_TOTAL_BYTES,
@@ -158,6 +159,7 @@ __all__ = [
"ChatMessagesOffload",
"ChatRole",
"ChatSubmittedMessage",
"apply_worker_chat_defaults",
"build_chat_extras",
"build_chat_image_key",
"build_chat_job_payload",
@@ -545,8 +547,11 @@ class ChatCompletionRequest(BaseModel):
"""A sampling seed, passed to the worker. Only workers that declare ``seed`` serve a request that sets it."""
reasoning_effort: ChatReasoningEffort | None = None
"""How much the model reasons before answering, one of OpenAI's values, passed to the worker unchanged and without
a default. Only workers that declare ``reasoning_effort`` serve a request that sets it, ``none`` included."""
"""How much the model reasons before answering, one of OpenAI's values, passed to the worker unchanged. Only
workers that declare ``reasoning_effort`` serve a request that sets it, ``none`` included. When it is unset, a
worker that declares the feature receives
[`DEFAULT_CHAT_REASONING_EFFORT`][horde.chat_contract.DEFAULT_CHAT_REASONING_EFFORT] (``medium``, OpenAI's
default), and any chat worker may serve the request."""
stop: StrictStr | list[StrictStr] | None = None
"""One stop sequence or a list of them."""
@@ -2190,7 +2195,9 @@ def build_chat_job_payload(params: Mapping[str, Any], *, max_length: int, max_co
the SDK's ``CHAT_SAMPLING_DEFAULTS`` is present, with its
default where the client left it unset; ``stop_sequence``, ``seed`` and ``reasoning_effort`` only when the client
sent them. There is
no prompt.
no prompt. The payload is built once per request. A ``reasoning_effort`` the client did not send is added per
job, for a worker that declares the feature, by
[`apply_worker_chat_defaults`][horde.classes.kobold.chat_request.apply_worker_chat_defaults].
Args:
params: The request's stored text params.
@@ -2210,3 +2217,22 @@ def build_chat_job_payload(params: Mapping[str, Any], *, max_length: int, max_co
chat_payload[MAX_CONTEXT_LENGTH_PARAMETER] = max_context_length
chat_payload["n"] = SUPPORTED_CHAT_CHOICE_COUNT
return chat_payload
def apply_worker_chat_defaults(chat_payload: dict[str, Any], worker_chat_features: Collection[str] | None) -> None:
"""Add the defaults that depend on the popping worker's declaration to a chat job's payload.
A worker that declares ``reasoning_effort`` receives
[`DEFAULT_CHAT_REASONING_EFFORT`][horde.chat_contract.DEFAULT_CHAT_REASONING_EFFORT] when the client did not send
a value. The default is applied to one job's copy of the payload, so the stored request, its required features
and its kudos estimate are the same as for a request without it. A worker that does not declare the feature
receives the payload unchanged.
Args:
chat_payload: One job's copy of the payload, from
[`build_chat_job_payload`][horde.classes.kobold.chat_request.build_chat_job_payload]. Changed in place.
worker_chat_features: The worker's stored ``chat_features``.
"""
worker_chat_features = worker_chat_features or ()
if ChatFeature.REASONING_EFFORT.value in worker_chat_features and chat_payload.get("reasoning_effort") is None:
chat_payload["reasoning_effort"] = DEFAULT_CHAT_REASONING_EFFORT.value
+5 -1
View File
@@ -23,6 +23,7 @@ from horde.classes.kobold.chat_request import (
CHAT_IMAGE_DATA_URL_PREFIX,
MAX_CHAT_FAULT_REASON_LENGTH,
MAX_CHAT_REFUSAL_RC_LENGTH,
apply_worker_chat_defaults,
build_chat_job_payload,
build_chat_messages_key,
build_chat_request_traits,
@@ -354,12 +355,15 @@ class TextWaitingPrompt(WaitingPrompt):
[`CHAT_MESSAGES_URL_SECONDS`][horde.classes.kobold.waiting_prompt.CHAT_MESSAGES_URL_SECONDS]; and the
``tools``, ``tool_choice``, ``parallel_tool_calls`` and ``response_format`` the request stored, narrowed by
[`build_worker_chat_extras`][horde.classes.kobold.chat_request.build_worker_chat_extras]; the response
carries ``chat: true``. A text job's response is unchanged.
carries ``chat: true``. A text job's response is unchanged. The defaults that depend on the worker's chat
declaration are added by
[`apply_worker_chat_defaults`][horde.classes.kobold.chat_request.apply_worker_chat_defaults].
"""
if not self.is_chat:
return super().get_pop_payload(procgen_list, payload)
chat_payload = dict(payload)
apply_worker_chat_defaults(chat_payload, procgen_list[0].worker.chat_features)
if procgen_list[0].image_transport == ChatImageTransport.MESSAGES_URL:
# Signing is local, so no store request is made while the pop response is built.
chat_payload["messages_url"] = generate_chat_image_download_url(
+3 -1
View File
@@ -758,7 +758,9 @@ class ChatCompletionRequest(BaseModel):
"""The sampling seed."""
reasoning_effort: ChatReasoningEffort | None = None
"""How much the model reasons before answering, forwarded unchanged; needs a worker that declares it."""
"""How much the model reasons before answering, forwarded unchanged. A value needs a worker that declares the
feature. When it is omitted, a declaring worker receives ``medium``, and ``max_tokens`` has to cover the reasoning
and the answer, since a reply cut off while thinking cannot be continued."""
n: StrictInt | None = None
"""How many choices to generate; only 1 is served."""
+28
View File
@@ -508,6 +508,34 @@ def test_a_reasoning_effort_waits_for_a_worker_that_declares_it_and_reaches_the_
_cancel(client, request_headers, "chat", request_id)
def test_an_omitted_reasoning_effort_reaches_a_declaring_worker_as_medium(
client,
request_headers: dict[str, str],
chat_worker: FakeChatWorker,
) -> None:
"""``medium`` is OpenAI's default."""
request_id = _submit_chat_request(client, request_headers)
with_feature = chat_worker.pop(chat_features=["reasoning_effort"])
assert with_feature["id"] is not None, with_feature
assert with_feature["payload"]["reasoning_effort"] == "medium"
_cancel(client, request_headers, "chat", request_id)
def test_an_omitted_reasoning_effort_reaches_a_worker_without_the_feature_without_the_key(
client,
request_headers: dict[str, str],
chat_worker: FakeChatWorker,
) -> None:
"""An omitted value does not restrict routing, so a worker that does not declare the feature serves the request."""
request_id = _submit_chat_request(client, request_headers)
without_feature = chat_worker.pop()
assert without_feature["id"] is not None, without_feature
assert "reasoning_effort" not in without_feature["payload"]
_cancel(client, request_headers, "chat", request_id)
def _age_worker_into_the_check_in_debounce(app, worker_name: str) -> None:
"""Make every following pop within 30 seconds a debounced check-in, as for a long-running worker."""
from horde.classes.kobold.worker import TextWorker
+58 -1
View File
@@ -60,6 +60,7 @@ from horde.classes.kobold.chat_request import (
DEFAULT_CHAT_MAX_TOKENS,
UNSUPPORTED_CHAT_PARAMETER_RC,
ChatSubmittedMessage,
apply_worker_chat_defaults,
build_chat_extras,
build_chat_image_key,
build_chat_job_payload,
@@ -634,7 +635,7 @@ OTHER_TOOL: dict[str, Any] = {
class TestReasoningEffort:
"""``reasoning_effort`` is OpenAI's value set, stored and forwarded unchanged, with no default."""
"""``reasoning_effort`` is OpenAI's value set, stored and forwarded unchanged. An unset value is not stored."""
@pytest.mark.parametrize("reasoning_effort", [effort.value for effort in ChatReasoningEffort])
def test_each_openai_value_is_stored_unchanged(self, reasoning_effort: str) -> None:
@@ -893,6 +894,14 @@ class TestRequiredFeatures:
"""A backend that ignores the key would reason anyway, so ``none`` needs the feature as any other value does."""
assert ChatFeature.REASONING_EFFORT in _required_features(_body(reasoning_effort="none"), conformance)
@pytest.mark.parametrize("conformance", list(ChatConformance))
def test_an_omitted_reasoning_effort_does_not_need_the_reasoning_effort_feature(
self,
conformance: ChatConformance,
) -> None:
"""The ``medium`` default a declaring worker receives does not restrict which workers serve the request."""
assert ChatFeature.REASONING_EFFORT not in _required_features(_body(), conformance)
def test_image_parts_need_the_vision_feature(self) -> None:
image_part = {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}
@@ -1903,6 +1912,54 @@ class TestChatJobPayload:
assert payload["reasoning_effort"] == "xhigh"
class TestWorkerChatDefaults:
"""The per-job defaults that depend on the popping worker's chat declaration."""
@staticmethod
def _payload(**overrides: Any) -> dict[str, Any]:
params = build_text_params(parse_chat_request(_body(**overrides)))
return build_chat_job_payload(params, max_length=64, max_context_length=2048)
def test_a_declaring_worker_receives_medium_when_the_client_omitted_reasoning_effort(self) -> None:
chat_payload = self._payload()
apply_worker_chat_defaults(chat_payload, [ChatFeature.REASONING_EFFORT.value])
assert chat_payload["reasoning_effort"] == "medium"
@pytest.mark.parametrize("reasoning_effort", [effort.value for effort in ChatReasoningEffort])
def test_a_value_the_client_sent_reaches_a_declaring_worker_unchanged(self, reasoning_effort: str) -> None:
chat_payload = self._payload(reasoning_effort=reasoning_effort)
apply_worker_chat_defaults(chat_payload, [ChatFeature.REASONING_EFFORT.value])
assert chat_payload["reasoning_effort"] == reasoning_effort
@pytest.mark.parametrize("worker_chat_features", [None, [], [ChatFeature.SEED.value]])
def test_a_worker_without_the_declaration_receives_no_reasoning_effort(
self,
worker_chat_features: list[str] | None,
) -> None:
chat_payload = self._payload()
unchanged_payload = dict(chat_payload)
apply_worker_chat_defaults(chat_payload, worker_chat_features)
assert chat_payload == unchanged_payload
assert "reasoning_effort" not in chat_payload
def test_the_default_is_applied_to_the_job_copy_and_not_to_the_stored_params(self) -> None:
params = build_text_params(parse_chat_request(_body()))
stored_payload = build_chat_job_payload(params, max_length=64, max_context_length=2048)
job_payload = dict(stored_payload)
apply_worker_chat_defaults(job_payload, [ChatFeature.REASONING_EFFORT.value])
assert "reasoning_effort" not in params
assert "reasoning_effort" not in stored_payload
assert job_payload["reasoning_effort"] == "medium"
class TestMessages:
def test_messages_serialize_in_the_shape_sent_without_unset_keys(self) -> None:
messages = [