mirror of
https://github.com/Haidra-Org/AI-Horde.git
synced 2026-11-06 05:17:31 +00:00
With no eligible worker the free allowance takes its floor, and a request over it was refused for kudos that do not help until a worker that can serve it comes online. - A text or chat request the kudos rules would refuse while no online worker passes the dispatch gates is refused 403 NoEligibleWorker (TextAsyncGenerate.refuse_when_no_worker_is_eligible). The waiting prompt is deleted and the decision counter records refused. - The NoEligibleWorker message states the missing declarations or exceeded limits when they are the cause. The body carries impossible_reason in the status route's shape. - handle_bad_requests merges an exception's body_fields into the error body. Exceptions without body_fields keep the message and rc body. - Funded requests and requests within the allowance are created and queued whether or not a worker can serve them. - The front reads impossible_reason from a native error body onto HordeAPIError. _horde_error maps NoEligibleWorker to no_worker_supports_features, no_worker_supports_limit or no_eligible_worker. - The front's gap and no-eligible-worker errors take request_id and job_may_exist. A refusal at submit carries no request id and does not state that the request was cancelled. - _size_limit_gap_chat_offers takes the gap directly, so a submit refused on a size limit gap states the largest declared limit. - NoEligibleWorker is in KNOWN_RC, README_return_codes.md, ADR 0037, chat_requests.md and chat_front.md.
2842 lines
119 KiB
Python
2842 lines
119 KiB
Python
# SPDX-FileCopyrightText: 2026 Tazlin <tazlin@haidra.net>
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Chat requests end to end: the chat routes, the chat declaration on text pop, structured submit and signals."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
import fakeredis
|
|
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,
|
|
build_faulted_chat_submit_body,
|
|
)
|
|
|
|
CHAT_MODEL = "elinas/chronos-70b-v2"
|
|
WORKER_NAME = "CICD Fake Chat Scribe"
|
|
CHAT_MESSAGES = [
|
|
{"role": "system", "content": "You are a terse assistant."},
|
|
{"role": "user", "content": [{"type": "text", "text": "Name one colour."}]},
|
|
]
|
|
TEXT_POP_RESPONSE_KEYS = {"payload", "id", "ids", "messages", "ttl", "extra_source_images", "skipped", "softprompt", "model"}
|
|
"""The top-level keys of a text job's pop response, which chat support leaves unchanged."""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_rate_limit() -> Iterator[None]:
|
|
"""Lift the per-address submit limit, which this module's request volume would otherwise trip."""
|
|
from horde.limiter import limiter
|
|
|
|
previous = limiter.enabled
|
|
limiter.enabled = False
|
|
yield
|
|
limiter.enabled = previous
|
|
|
|
|
|
@pytest.fixture
|
|
def chat_events() -> Iterator[Any]:
|
|
"""Route chat signals to an in-memory Redis and return a subscription to every chat channel."""
|
|
fake_publisher = fakeredis.FakeStrictRedis(decode_responses=True)
|
|
previous_publisher = chat_signal._publisher
|
|
chat_signal._publisher = fake_publisher
|
|
subscription = fake_publisher.pubsub()
|
|
subscription.psubscribe(f"{chat_signal.CHAT_SIGNAL_CHANNEL_PREFIX}*")
|
|
subscription.get_message(timeout=1)
|
|
try:
|
|
yield subscription
|
|
finally:
|
|
subscription.close()
|
|
chat_signal._publisher = previous_publisher
|
|
|
|
|
|
@pytest.fixture
|
|
def chat_worker(client, request_headers: dict[str, str]) -> FakeChatWorker:
|
|
"""A worker serving the chat model, declaring the koboldcpp feature set."""
|
|
return FakeChatWorker(client=client, headers=request_headers, name=WORKER_NAME, models=[CHAT_MODEL])
|
|
|
|
|
|
def _received_events(subscription: Any) -> list[tuple[str, dict[str, Any]]]:
|
|
events: list[tuple[str, dict[str, Any]]] = []
|
|
while True:
|
|
message = subscription.get_message(timeout=0.2)
|
|
if message is None:
|
|
return events
|
|
if message["type"] == "pmessage":
|
|
events.append((message["channel"], json.loads(message["data"])))
|
|
|
|
|
|
def _submit_chat_request(client, request_headers: dict[str, str], **overrides: Any) -> str:
|
|
chat_body: dict[str, Any] = {
|
|
"model": CHAT_MODEL,
|
|
"messages": CHAT_MESSAGES,
|
|
"temperature": 0.5,
|
|
"stop": "\nuser:",
|
|
}
|
|
chat_body.update(overrides)
|
|
response = client.post("/api/v2/generate/chat/async", json=chat_body, headers=request_headers)
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
return response.get_json()["id"]
|
|
|
|
|
|
def _submit_text_request(client, request_headers: dict[str, str], prompt: str = "a plain text prompt for a plain text worker") -> str:
|
|
text_body = {
|
|
"prompt": prompt,
|
|
"params": {"max_length": 80},
|
|
"models": [CHAT_MODEL],
|
|
}
|
|
response = client.post("/api/v2/generate/text/async", json=text_body, headers=request_headers)
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
return response.get_json()["id"]
|
|
|
|
|
|
def _cancel(client, request_headers: dict[str, str], route_family: str, request_id: str) -> None:
|
|
response = client.delete(f"/api/v2/generate/{route_family}/status/{request_id}", headers=request_headers)
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
|
|
|
|
def _chat_status(client, request_headers: dict[str, str], request_id: str) -> dict[str, Any]:
|
|
response = client.get(f"/api/v2/generate/chat/status/{request_id}", headers=request_headers)
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
return response.get_json()
|
|
|
|
|
|
def test_a_chat_request_completes_with_its_structured_reply_and_signals(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
chat_events: Any,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
|
|
pop_result = chat_worker.pop()
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["chat"] is True
|
|
assert pop_result["model"] == CHAT_MODEL
|
|
assert pop_result["payload"] == {
|
|
"messages": CHAT_MESSAGES,
|
|
"temperature": 0.5,
|
|
"top_p": 1.0,
|
|
"frequency_penalty": 0.0,
|
|
"presence_penalty": 0.0,
|
|
"rep_pen": 1.0,
|
|
"top_k": 0,
|
|
"min_p": 0.0,
|
|
"stop_sequence": ["\nuser:"],
|
|
"max_length": 512,
|
|
"max_context_length": 2048,
|
|
"n": 1,
|
|
}
|
|
|
|
submit_response = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=pop_result["id"],
|
|
content="Blue.",
|
|
model_id=CHAT_MODEL,
|
|
prompt_tokens=21,
|
|
completion_tokens=3,
|
|
native_finish_reason="eos_token",
|
|
),
|
|
)
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["done"] is True
|
|
[generation] = status["generations"]
|
|
assert generation["text"] == "Blue."
|
|
assert generation["message"] == {"role": "assistant", "content": "Blue."}
|
|
assert generation["worker_name"] == WORKER_NAME
|
|
assert generation["finish_reason"] == "stop"
|
|
assert generation["native_finish_reason"] == "eos_token"
|
|
assert generation["prompt_tokens"] == 21
|
|
assert generation["completion_tokens"] == 3
|
|
assert generation["charge_basis"] == "output_estimate"
|
|
assert generation["queue_wait_seconds"] >= 0
|
|
|
|
channel = chat_signal_channel(request_id)
|
|
assert _received_events(chat_events) == [
|
|
(channel, {"id": request_id, "event": "generation", "generations": 1}),
|
|
(channel, {"id": request_id, "event": "done", "generations": 1}),
|
|
]
|
|
|
|
|
|
def test_unreported_token_counts_and_backend_reason_are_null_on_the_status(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
submit_response = chat_worker.submit(build_chat_submit_body(generation_id=pop_result["id"], content="Red.", model_id=CHAT_MODEL))
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
|
|
[generation] = _chat_status(client, request_headers, request_id)["generations"]
|
|
assert generation["native_finish_reason"] is None
|
|
assert generation["prompt_tokens"] is None
|
|
assert generation["completion_tokens"] is None
|
|
assert generation["reasoning_tokens"] is None
|
|
assert "reasoning_content" not in generation["message"]
|
|
|
|
|
|
def test_the_structured_reply_is_written_with_the_generation_and_charged_as_text(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
from horde.classes.kobold.processing_generation import TextProcessingGeneration
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
chat_request_id = _submit_chat_request(client, request_headers, max_tokens=80)
|
|
chat_pop = chat_worker.pop()
|
|
chat_submit = chat_worker.submit(build_chat_submit_body(generation_id=chat_pop["id"], content="Blue.", model_id=CHAT_MODEL))
|
|
assert chat_submit.status_code == 200, chat_submit.get_data(as_text=True)
|
|
|
|
with app.app_context():
|
|
chat_request = db.session.get(TextWaitingPrompt, uuid.UUID(chat_request_id))
|
|
flattened_prompt = chat_request.prompt
|
|
stored = db.session.get(TextProcessingGeneration, uuid.UUID(chat_pop["id"]))
|
|
assert stored.generation == "Blue."
|
|
assert stored.message == {"role": "assistant", "content": "Blue."}
|
|
assert stored.charge_basis == "output_estimate"
|
|
assert stored.assigned_model == CHAT_MODEL
|
|
|
|
text_request_id = _submit_text_request(client, request_headers, prompt=flattened_prompt)
|
|
text_pop = chat_worker.pop()
|
|
assert text_pop["id"] is not None, text_pop
|
|
text_submit = chat_worker.submit({"id": text_pop["id"], "generation": "Blue.", "state": "ok", "seed": 0})
|
|
assert text_submit.status_code == 200, text_submit.get_data(as_text=True)
|
|
|
|
assert chat_submit.get_json()["reward"] == text_submit.get_json()["reward"]
|
|
text_status = client.get(f"/api/v2/generate/text/status/{text_request_id}", headers=request_headers).get_json()
|
|
assert "charge_basis" not in text_status["generations"][0]
|
|
|
|
|
|
def test_an_empty_reply_completes_charges_and_is_counted_as_empty(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
monkeypatch,
|
|
) -> None:
|
|
from horde.classes.kobold import processing_generation
|
|
|
|
empty_generation_counts: list[dict[str, str]] = []
|
|
|
|
class RecordingCounter:
|
|
def add(self, amount: int, attributes: dict[str, str]) -> None:
|
|
empty_generation_counts.append(attributes)
|
|
|
|
monkeypatch.setattr(processing_generation, "text_empty_generations", RecordingCounter())
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
submit_response = chat_worker.submit(build_chat_submit_body(generation_id=pop_result["id"], content="", model_id=CHAT_MODEL))
|
|
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
assert submit_response.get_json()["reward"] > 0
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["done"] is True
|
|
assert status["generations"][0]["message"] == {"role": "assistant", "content": ""}
|
|
assert [attributes["model"] for attributes in empty_generation_counts] == [CHAT_MODEL]
|
|
|
|
|
|
def test_the_settlement_log_line_ends_with_the_gentype_of_a_chat_or_text_request(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""The settlement line of a chat reply ends with ``gentype=chat`` and that of a text generation with ``gentype=text``."""
|
|
from horde.logger import logger
|
|
|
|
settlement_lines: list[str] = []
|
|
sink_id = logger.add(
|
|
lambda message: settlement_lines.append(message.record["message"]),
|
|
level="INFO",
|
|
filter=lambda record: " kudos, delivered by worker: " in record["message"],
|
|
)
|
|
try:
|
|
_submit_chat_request(client, request_headers, max_tokens=80)
|
|
chat_pop = chat_worker.pop()
|
|
chat_submit = chat_worker.submit(build_chat_submit_body(generation_id=chat_pop["id"], content="Blue.", model_id=CHAT_MODEL))
|
|
assert chat_submit.status_code == 200, chat_submit.get_data(as_text=True)
|
|
_submit_text_request(client, request_headers)
|
|
text_pop = chat_worker.pop()
|
|
assert text_pop["id"] is not None, text_pop
|
|
text_submit = chat_worker.submit({"id": text_pop["id"], "generation": "Blue.", "state": "ok", "seed": 0})
|
|
assert text_submit.status_code == 200, text_submit.get_data(as_text=True)
|
|
finally:
|
|
logger.remove(sink_id)
|
|
|
|
assert [line.split(" ")[-1] for line in settlement_lines] == [
|
|
"gentype=chat",
|
|
"gentype=text",
|
|
]
|
|
assert f"Generation {chat_pop['id']} worth " in settlement_lines[0]
|
|
|
|
|
|
def test_a_submit_naming_another_model_than_the_assigned_one_is_refused(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
response = chat_worker.submit(build_chat_submit_body(generation_id=pop_result["id"], content="Blue.", model_id="another/model"))
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatModelMismatch"
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_the_assigned_model_and_limits_survive_a_later_change_of_declaration(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
from horde.classes.kobold.processing_generation import TextProcessingGeneration
|
|
from horde.flask import db
|
|
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop(chat_max_context_length=8192, chat_max_length=1024)
|
|
chat_worker.pop(chat_max_context_length=4096, chat_max_length=600)
|
|
|
|
with app.app_context():
|
|
stored = db.session.get(TextProcessingGeneration, uuid.UUID(pop_result["id"]))
|
|
assert (stored.assigned_model, stored.assigned_max_context_length, stored.assigned_max_length) == (CHAT_MODEL, 8192, 1024)
|
|
response = chat_worker.submit(build_chat_submit_body(generation_id=pop_result["id"], content="Blue.", model_id=CHAT_MODEL))
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
assert _chat_status(client, request_headers, request_id)["done"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"submit_body",
|
|
[
|
|
{"generation": "Blue.", "state": "ok"},
|
|
{"message": {"role": "assistant", "content": "Blue."}, "model_id": CHAT_MODEL, "state": "ok"},
|
|
{"message": {"role": "assistant", "content": "Blue.", "audio": {"id": "a"}}, "finish_reason": "stop", "model_id": CHAT_MODEL},
|
|
{"message": {"role": "assistant", "content": "Blue."}, "generation": "Blue.", "finish_reason": "stop", "model_id": CHAT_MODEL},
|
|
{"message": {"role": "assistant", "content": "Blue.", "tool_calls": []}, "finish_reason": "stop", "model_id": CHAT_MODEL},
|
|
{"message": {"role": "assistant", "content": None}, "finish_reason": "stop", "model_id": CHAT_MODEL},
|
|
{"message": {"role": "assistant", "content": "Blue."}, "finish_reason": "tool_calls", "model_id": CHAT_MODEL},
|
|
],
|
|
ids=[
|
|
"generation-only",
|
|
"no-finish-reason",
|
|
"extra-message-key",
|
|
"message-and-generation",
|
|
"empty-tool-calls",
|
|
"null-content-without-tool-calls",
|
|
"tool-calls-finish-without-tool-calls",
|
|
],
|
|
)
|
|
def test_a_chat_submit_without_a_valid_structured_reply_is_refused(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
submit_body: dict[str, Any],
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
response = chat_worker.submit({"id": pop_result["id"], **submit_body})
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatSubmitInvalid"
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_submitted_message_from_another_role_than_assistant_is_refused(client, request_headers: dict[str, str]) -> None:
|
|
submit_body = build_chat_submit_body(generation_id="00000000-0000-0000-0000-000000000000", content="Blue.", model_id=CHAT_MODEL)
|
|
submit_body["message"]["role"] = "user"
|
|
|
|
response = client.post("/api/v2/generate/text/submit", json=submit_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
|
|
|
|
def test_a_faulted_chat_submit_needs_neither_generation_nor_message(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
response = chat_worker.submit(build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL))
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
assert response.get_json()["reward"] == 0
|
|
reissued = chat_worker.pop()
|
|
assert reissued["id"] not in (None, pop_result["id"])
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_request_faults_on_the_submit_that_takes_it_past_the_faulted_generations_limit(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
chat_events: Any,
|
|
) -> None:
|
|
"""The submit route faults the request and signals its client without waiting for the stale-generation thread."""
|
|
from horde.classes.base.waiting_prompt import FAULTED_GENERATIONS_LIMIT
|
|
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
for _ in range(FAULTED_GENERATIONS_LIMIT + 1):
|
|
assert _chat_status(client, request_headers, request_id)["faulted"] is False
|
|
pop_result = chat_worker.pop()
|
|
assert pop_result["id"] is not None, pop_result
|
|
response = chat_worker.submit(build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL))
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["faulted"] is True
|
|
assert chat_worker.pop()["id"] is None
|
|
assert (chat_signal_channel(request_id), {"id": request_id, "event": "faulted", "generations": 0}) in _received_events(chat_events)
|
|
|
|
|
|
def test_a_text_submit_without_generation_is_still_refused_by_the_parser(client, request_headers: dict[str, str]) -> None:
|
|
response = client.post(
|
|
"/api/v2/generate/text/submit",
|
|
json={"id": "00000000-0000-0000-0000-000000000000", "state": "faulted"},
|
|
headers=request_headers,
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "generation" in response.get_data(as_text=True)
|
|
|
|
|
|
def test_a_worker_without_the_chat_declaration_never_pops_a_chat_job(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
|
|
for chat_jobs in (None, False):
|
|
pop_result = chat_worker.pop(chat_jobs=chat_jobs)
|
|
assert pop_result["id"] is None, pop_result
|
|
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_chat_worker_still_pops_text_jobs_with_an_unchanged_response(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_text_request(client, request_headers)
|
|
|
|
pop_result = chat_worker.pop()
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert set(pop_result) == TEXT_POP_RESPONSE_KEYS
|
|
assert pop_result["payload"]["prompt"] == "a plain text prompt for a plain text worker"
|
|
assert "messages" not in pop_result["payload"]
|
|
submit_response = chat_worker.submit({"id": pop_result["id"], "generation": "done", "state": "ok", "seed": 0})
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
text_status = client.get(f"/api/v2/generate/text/status/{request_id}", headers=request_headers).get_json()
|
|
assert "finish_reason" not in text_status["generations"][0]
|
|
|
|
|
|
def test_chat_limits_and_not_text_limits_admit_a_chat_request(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, horde={"max_context_length": 6144})
|
|
|
|
too_small = chat_worker.pop(chat_max_context_length=4096, max_context_length=8192)
|
|
assert too_small["id"] is None, too_small
|
|
fits = chat_worker.pop(chat_max_context_length=8192, max_context_length=2048)
|
|
assert fits["id"] is not None, fits
|
|
assert fits["payload"]["max_context_length"] == 6144
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_request_needing_an_undeclared_feature_waits_for_a_worker_that_declares_it(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, frequency_penalty=0.5)
|
|
|
|
without_feature = chat_worker.pop()
|
|
assert without_feature["id"] is None, without_feature
|
|
with_feature = chat_worker.pop(chat_features=["frequency_penalty", "not_a_known_feature"])
|
|
assert with_feature["id"] is not None, with_feature
|
|
assert with_feature["payload"]["frequency_penalty"] == 0.5
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_reasoning_effort_waits_for_a_worker_that_declares_it_and_reaches_the_payload_unchanged(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""``none`` needs the feature as any other value does, since a backend that ignores the key would reason anyway."""
|
|
request_id = _submit_chat_request(client, request_headers, reasoning_effort="none")
|
|
|
|
without_feature = chat_worker.pop()
|
|
assert without_feature["id"] is None, without_feature
|
|
with_feature = chat_worker.pop(chat_features=["reasoning_effort"])
|
|
assert with_feature["id"] is not None, with_feature
|
|
assert with_feature["payload"]["reasoning_effort"] == "none"
|
|
_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
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
worker = db.session.query(TextWorker).filter_by(name=worker_name).one()
|
|
worker.created = datetime.utcnow() - timedelta(minutes=10)
|
|
worker.last_check_in = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
|
|
def _stored_accepts_chat_jobs(app, worker_name: str) -> bool:
|
|
from horde.classes.kobold.worker import TextWorker
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
return bool(db.session.query(TextWorker.accepts_chat_jobs).filter_by(name=worker_name).scalar())
|
|
|
|
|
|
def test_each_pop_is_judged_by_its_own_chat_declaration_inside_the_check_in_debounce(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""A debounced check-in still replaces the chat declaration, so the pop's eligibility follows what it sent."""
|
|
fitting_declaration: dict[str, Any] = {"chat_features": ["frequency_penalty"], "chat_max_context_length": 8192}
|
|
chat_worker.pop(**fitting_declaration)
|
|
_age_worker_into_the_check_in_debounce(app, WORKER_NAME)
|
|
request_id = _submit_chat_request(client, request_headers, frequency_penalty=0.5, horde={"max_context_length": 6144})
|
|
|
|
for chat_jobs in (None, False):
|
|
undeclared = chat_worker.pop(chat_jobs=chat_jobs)
|
|
assert undeclared["id"] is None, undeclared
|
|
assert not _stored_accepts_chat_jobs(app, WORKER_NAME)
|
|
too_small = chat_worker.pop(chat_features=["frequency_penalty"], chat_max_context_length=4096)
|
|
assert too_small["id"] is None, too_small
|
|
without_feature = chat_worker.pop(chat_max_context_length=8192)
|
|
assert without_feature["id"] is None, without_feature
|
|
fits = chat_worker.pop(**fitting_declaration)
|
|
|
|
assert fits["id"] is not None, fits
|
|
assert fits["payload"]["frequency_penalty"] == 0.5
|
|
assert _stored_accepts_chat_jobs(app, WORKER_NAME)
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_request_at_default_values_needs_no_feature(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, frequency_penalty=0, presence_penalty=0)
|
|
|
|
pop_result = chat_worker.pop(chat_features=[])
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("pop_overrides", "message_fragment"),
|
|
[
|
|
({"model_id": "koboldcpp/model_Q4"}, "one of the worker's models"),
|
|
({"model_id": ""}, "model_id"),
|
|
],
|
|
ids=["not-a-declared-model", "no-model-id"],
|
|
)
|
|
def test_an_invalid_chat_declaration_is_refused_on_pop(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
pop_overrides: dict[str, Any],
|
|
message_fragment: str,
|
|
) -> None:
|
|
response = chat_worker.pop_response(**pop_overrides)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatDeclarationInvalid"
|
|
assert message_fragment in response.get_json()["message"]
|
|
|
|
|
|
def test_a_chat_model_id_containing_a_colon_is_accepted_on_pop(client, request_headers: dict[str, str]) -> None:
|
|
"""The native pop treats ``model_id`` as an opaque name, so a colon in it is not refused."""
|
|
colon_model = "koboldcpp/model:Q4"
|
|
colon_worker = FakeChatWorker(client=client, headers=request_headers, name=WORKER_NAME, models=[colon_model])
|
|
|
|
response = colon_worker.pop_response(model_id=colon_model)
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
|
|
|
|
def test_chat_jobs_without_chat_limits_is_refused_on_pop(client, request_headers: dict[str, str]) -> None:
|
|
pop_body = {
|
|
"name": WORKER_NAME,
|
|
"models": [CHAT_MODEL],
|
|
"bridge_agent": request_headers["Client-Agent"],
|
|
"chat_jobs": True,
|
|
"model_id": CHAT_MODEL,
|
|
}
|
|
|
|
response = client.post("/api/v2/generate/text/pop", json=pop_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatDeclarationInvalid"
|
|
|
|
|
|
def test_prompt_jobs_off_without_chat_jobs_is_refused_on_pop(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""A worker that turns text jobs off without opting into chat jobs could receive no job, so the pop is refused."""
|
|
for chat_jobs in (None, False):
|
|
response = chat_worker.pop_response(chat_jobs=chat_jobs, prompt_jobs=False)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatDeclarationInvalid"
|
|
assert "prompt_jobs" in response.get_json()["message"]
|
|
|
|
|
|
def test_a_chat_only_worker_pops_chat_jobs_and_never_text_jobs(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""With prompt_jobs false a worker receives the queued chat job and leaves the queued text job waiting."""
|
|
text_request_id = _submit_text_request(client, request_headers)
|
|
chat_request_id = _submit_chat_request(client, request_headers)
|
|
|
|
chat_only = chat_worker.pop(prompt_jobs=False)
|
|
nothing_else = chat_worker.pop(prompt_jobs=False)
|
|
text_pop = chat_worker.pop()
|
|
|
|
assert chat_only["id"] is not None, chat_only
|
|
assert chat_only["chat"] is True
|
|
assert nothing_else["id"] is None, nothing_else
|
|
assert text_pop["id"] is not None, text_pop
|
|
assert text_pop["payload"]["prompt"] == "a plain text prompt for a plain text worker"
|
|
_cancel(client, request_headers, "chat", chat_request_id)
|
|
_cancel(client, request_headers, "text", text_request_id)
|
|
|
|
|
|
def test_a_chat_only_pop_needs_no_text_limits(client, request_headers: dict[str, str]) -> None:
|
|
"""max_length and max_context_length bound only text jobs, so a chat-only pop may leave them out."""
|
|
pop_body = {
|
|
"name": WORKER_NAME,
|
|
"models": [CHAT_MODEL],
|
|
"bridge_agent": request_headers["Client-Agent"],
|
|
"chat_jobs": True,
|
|
"prompt_jobs": False,
|
|
"chat_limits": {"max_context_length": 8192, "max_length": 1024},
|
|
"model_id": CHAT_MODEL,
|
|
}
|
|
|
|
response = client.post("/api/v2/generate/text/pop", json=pop_body, headers=request_headers)
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
|
|
|
|
def test_the_text_job_opt_out_is_applied_inside_the_check_in_debounce(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""A debounced check-in still stores prompt_jobs, so the next pop's text eligibility follows what it sent."""
|
|
chat_worker.pop()
|
|
_age_worker_into_the_check_in_debounce(app, WORKER_NAME)
|
|
text_request_id = _submit_text_request(client, request_headers)
|
|
|
|
chat_only = chat_worker.pop(prompt_jobs=False)
|
|
assert chat_only["id"] is None, chat_only
|
|
assert not _stored_accepts_prompt_jobs(app, WORKER_NAME)
|
|
text_pop = chat_worker.pop()
|
|
|
|
assert text_pop["id"] is not None, text_pop
|
|
assert _stored_accepts_prompt_jobs(app, WORKER_NAME)
|
|
_cancel(client, request_headers, "text", text_request_id)
|
|
|
|
|
|
def _stored_accepts_prompt_jobs(app, worker_name: str) -> bool:
|
|
from horde.classes.kobold.worker import TextWorker
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
return bool(db.session.query(TextWorker.accepts_prompt_jobs).filter_by(name=worker_name).scalar())
|
|
|
|
|
|
def test_a_developer_message_reaches_the_worker_as_a_system_message(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(
|
|
client,
|
|
request_headers,
|
|
messages=[{"role": "developer", "content": "Be terse."}, {"role": "user", "content": "Hi"}],
|
|
)
|
|
|
|
pop_result = chat_worker.pop()
|
|
|
|
assert pop_result["payload"]["messages"] == [{"role": "system", "content": "Be terse."}, {"role": "user", "content": "Hi"}]
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_each_route_family_reports_the_other_familys_requests_as_not_found(client, request_headers: dict[str, str]) -> None:
|
|
chat_request_id = _submit_chat_request(client, request_headers)
|
|
text_request_id = _submit_text_request(client, request_headers)
|
|
|
|
for method, path in (
|
|
(client.get, f"/api/v2/generate/text/status/{chat_request_id}"),
|
|
(client.delete, f"/api/v2/generate/text/status/{chat_request_id}"),
|
|
(client.get, f"/api/v2/generate/text/request/{chat_request_id}"),
|
|
(client.get, f"/api/v2/generate/chat/status/{text_request_id}"),
|
|
(client.delete, f"/api/v2/generate/chat/status/{text_request_id}"),
|
|
):
|
|
response = method(path, headers=request_headers)
|
|
assert response.status_code == 404, path
|
|
assert response.get_json()["rc"] == "RequestNotFound"
|
|
|
|
_cancel(client, request_headers, "chat", chat_request_id)
|
|
_cancel(client, request_headers, "text", text_request_id)
|
|
|
|
|
|
def test_there_is_no_chat_stored_parameters_route(client, request_headers: dict[str, str]) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
|
|
response = client.get(f"/api/v2/generate/chat/request/{request_id}", headers=request_headers)
|
|
|
|
assert response.status_code == 404
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_cancelling_a_chat_request_publishes_a_cancelled_event(client, request_headers: dict[str, str], chat_events: Any) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
assert _received_events(chat_events) == [
|
|
(chat_signal_channel(request_id), {"id": request_id, "event": "cancelled", "generations": 0}),
|
|
]
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["done"] is True
|
|
assert status["faulted"] is False
|
|
assert status["processing"] == status["waiting"] == 0
|
|
assert status["generations"] == []
|
|
|
|
|
|
def test_faulted_chat_requests_publish_a_faulted_event_and_text_requests_publish_none(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_events: Any,
|
|
) -> None:
|
|
from horde.database.threads import collect_chat_faults, publish_chat_faults
|
|
|
|
chat_request_id = _submit_chat_request(client, request_headers)
|
|
text_request_id = _submit_text_request(client, request_headers)
|
|
|
|
publish_chat_faults(collect_chat_faults([uuid.UUID(chat_request_id), uuid.UUID(text_request_id)]))
|
|
|
|
assert _received_events(chat_events) == [
|
|
(chat_signal_channel(chat_request_id), {"id": chat_request_id, "event": "faulted", "generations": 0}),
|
|
]
|
|
_cancel(client, request_headers, "chat", chat_request_id)
|
|
_cancel(client, request_headers, "text", text_request_id)
|
|
|
|
|
|
def test_an_expired_chat_request_is_signalled_after_its_row_is_gone(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
monkeypatch,
|
|
) -> None:
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.database import threads
|
|
from horde.flask import db
|
|
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
with app.app_context():
|
|
expired_request = db.session.get(TextWaitingPrompt, uuid.UUID(request_id))
|
|
expired_request.expiry = datetime.utcnow() - timedelta(hours=1)
|
|
db.session.commit()
|
|
published: list[tuple[str, str, bool]] = []
|
|
|
|
def record_publish(published_request_id: str, event: Any, generation_count: int, **_fault_details: Any) -> None:
|
|
row_still_exists = db.session.query(TextWaitingPrompt.id).filter_by(id=uuid.UUID(published_request_id)).first() is not None
|
|
published.append((published_request_id, event.value, row_still_exists))
|
|
|
|
monkeypatch.setattr(threads, "publish_chat_event", record_publish)
|
|
|
|
threads.check_waiting_prompts()
|
|
|
|
assert (request_id, "faulted", False) in published
|
|
assert client.get(f"/api/v2/generate/chat/status/{request_id}", headers=request_headers).status_code == 404
|
|
|
|
|
|
def test_turning_admission_off_refuses_new_chat_requests_and_keeps_serving_queued_ones(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
monkeypatch,
|
|
) -> None:
|
|
from horde.config import Config
|
|
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
monkeypatch.setattr(Config, "chat_admission_enabled", False)
|
|
|
|
refused = client.post("/api/v2/generate/chat/async", json={"model": CHAT_MODEL, "messages": CHAT_MESSAGES}, headers=request_headers)
|
|
assert refused.status_code == 503
|
|
assert refused.get_json()["rc"] == "ChatAdmissionDisabled"
|
|
|
|
assert _chat_status(client, request_headers, request_id)["done"] is False
|
|
pop_result = chat_worker.pop()
|
|
assert pop_result["id"] is not None, pop_result
|
|
submit_response = chat_worker.submit(build_chat_submit_body(generation_id=pop_result["id"], content="Blue.", model_id=CHAT_MODEL))
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
assert _chat_status(client, request_headers, request_id)["done"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"chat_body_overrides",
|
|
[
|
|
{"logprobs": True},
|
|
{"n": 2},
|
|
{"horde": {"models": ["other/model"]}},
|
|
{"messages": [{"role": "assistant", "content": [{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}]}]},
|
|
{"messages": [{"role": "user", "content": "Hi"}, {"role": "tool", "content": "Rain", "tool_call_id": "call_unknown"}]},
|
|
],
|
|
ids=["logprobs", "n", "horde-models", "assistant-image-part", "unanswered-tool-message"],
|
|
)
|
|
def test_an_unsupported_chat_parameter_is_refused_with_its_return_code(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_body_overrides: dict[str, Any],
|
|
) -> None:
|
|
chat_body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, **chat_body_overrides}
|
|
|
|
response = client.post("/api/v2/generate/chat/async", json=chat_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "UnsupportedChatParameter"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("chat_body_overrides", "named_key"),
|
|
[
|
|
({"temperature": 9}, "temperature"),
|
|
({"max_tokens": 8}, "max_length"),
|
|
({"max_tokens": MAX_LENGTH_LIMIT + 1}, "max_length"),
|
|
],
|
|
)
|
|
def test_a_chat_parameter_outside_the_text_range_is_refused_unclamped(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_body_overrides: dict[str, Any],
|
|
named_key: str,
|
|
) -> None:
|
|
chat_body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, **chat_body_overrides}
|
|
|
|
response = client.post("/api/v2/generate/chat/async", json=chat_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert named_key in response.get_data(as_text=True)
|
|
|
|
|
|
def test_differing_token_limits_are_refused(client, request_headers: dict[str, str]) -> None:
|
|
chat_body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, "max_tokens": 100, "max_completion_tokens": 200}
|
|
|
|
response = client.post("/api/v2/generate/chat/async", json=chat_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatRequestConflict"
|
|
|
|
|
|
def test_a_chat_dry_run_prices_the_request_without_queueing_it(client, request_headers: dict[str, str]) -> None:
|
|
chat_body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, "horde": {"dry_run": True}}
|
|
|
|
response = client.post("/api/v2/generate/chat/async", json=chat_body, headers=request_headers)
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
quote = response.get_json()
|
|
assert quote["kudos"] > 0
|
|
assert quote["resolved"]["context_fit"] == "grow"
|
|
assert quote["resolved"]["models"] == [CHAT_MODEL]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"usage_field, reported_value",
|
|
[
|
|
("prompt_tokens", -1),
|
|
("completion_tokens", 10_000_001),
|
|
("reasoning_tokens", -1),
|
|
("finish_reason", "tool_call"),
|
|
("native_finish_reason", "x" * 33),
|
|
],
|
|
)
|
|
def test_a_submit_with_an_invalid_reported_usage_is_refused(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
usage_field: str,
|
|
reported_value: object,
|
|
) -> None:
|
|
submit_body = {
|
|
"id": "00000000-0000-0000-0000-000000000000",
|
|
"message": {"role": "assistant", "content": "x"},
|
|
"model_id": CHAT_MODEL,
|
|
"finish_reason": "stop",
|
|
"state": "ok",
|
|
usage_field: reported_value,
|
|
}
|
|
|
|
response = client.post("/api/v2/generate/text/submit", json=submit_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
|
|
|
|
def test_a_validation_rewrite_of_the_prompt_is_applied_to_every_message_text(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
make_api_user,
|
|
monkeypatch,
|
|
) -> None:
|
|
from horde.classes.base.user import User
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.detection import prompt_checker
|
|
from horde.flask import db
|
|
|
|
flagged_user = make_api_user(kudos=1000)
|
|
with app.app_context():
|
|
db.session.get(User, flagged_user.id).set_flagged(True)
|
|
|
|
def replace_colour(prompt: str, models: list[str], already_replaced: bool = False) -> str:
|
|
return prompt.replace("colour", "hue")
|
|
|
|
# Flagged users always get the replacement filter; its regexes are not loaded in tests, so the filter is
|
|
# stood in for by a replacement that is visible in the result.
|
|
monkeypatch.setattr(prompt_checker, "nsfw_model_prompt_replace", replace_colour)
|
|
flagged_headers = {**request_headers, "apikey": flagged_user.api_key}
|
|
|
|
request_id = _submit_chat_request(client, flagged_headers)
|
|
|
|
pop_result = chat_worker.pop()
|
|
assert pop_result["payload"]["messages"] == [
|
|
{"role": "system", "content": "You are a terse assistant."},
|
|
{"role": "user", "content": [{"type": "text", "text": "Name one hue."}]},
|
|
]
|
|
with app.app_context():
|
|
submitted_prompt = db.session.query(TextWaitingPrompt.submitted_prompt).filter_by(id=uuid.UUID(request_id)).scalar()
|
|
assert submitted_prompt == "system: You are a terse assistant.\nuser: Name one colour."
|
|
_cancel(client, flagged_headers, "chat", request_id)
|
|
|
|
|
|
def _count_chat_requests(app, user_id: int) -> int:
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
return db.session.query(TextWaitingPrompt).filter(TextWaitingPrompt.user_id == user_id, TextWaitingPrompt.is_chat.is_(True)).count()
|
|
|
|
|
|
def _count_generation_debits(app, user_id: int) -> int:
|
|
"""Count the requester debits posted for a user, which an activation's horde tax is one of."""
|
|
from horde.classes.base.kudos import KudosLedger
|
|
from horde.enums import KudosEntryType
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
return (
|
|
db.session.query(KudosLedger)
|
|
.filter(KudosLedger.user_id == user_id, KudosLedger.entry_type == KudosEntryType.GENERATION)
|
|
.count()
|
|
)
|
|
|
|
|
|
def _chat_body(model: str = CHAT_MODEL, **horde_options: Any) -> dict[str, Any]:
|
|
chat_body: dict[str, Any] = {"model": model, "messages": CHAT_MESSAGES, "max_tokens": 64}
|
|
if horde_options:
|
|
chat_body["horde"] = horde_options
|
|
return chat_body
|
|
|
|
|
|
def _post_chat(client, headers: dict[str, str], chat_body: dict[str, Any]) -> Any:
|
|
return client.post("/api/v2/generate/chat/async", json=chat_body, headers=headers)
|
|
|
|
|
|
def _serve_custom_chat_model(client, headers: dict[str, str]) -> tuple[str, FakeChatWorker]:
|
|
"""Bring a worker online that serves chat jobs with a model the text reference does not list."""
|
|
custom_model = f"custom/chat-only-{uuid.uuid4().hex[:8]}"
|
|
worker_name = f"CICD Custom Chat Scribe {uuid.uuid4().hex[:8]}"
|
|
custom_worker = FakeChatWorker(client=client, headers=headers, name=worker_name, models=[custom_model])
|
|
custom_worker.pop()
|
|
return custom_model, custom_worker
|
|
|
|
|
|
def _take_worker_offline(app, worker_name: str) -> None:
|
|
from horde.classes.kobold.worker import TextWorker
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
worker = db.session.query(TextWorker).filter_by(name=worker_name).one()
|
|
worker.last_check_in = datetime.utcnow() - timedelta(hours=1)
|
|
db.session.commit()
|
|
|
|
|
|
def _set_worker_speed(app, worker_name: str, *, tokens_per_second: float) -> None:
|
|
"""Record a measured speed for a worker, as the speed refresh would after it completed jobs at that rate."""
|
|
from horde import vars as hv
|
|
from horde.classes.kobold.worker import TextWorker
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
worker = db.session.query(TextWorker).filter_by(name=worker_name).one()
|
|
worker.speed = tokens_per_second * hv.thing_divisors["text"]
|
|
db.session.commit()
|
|
|
|
|
|
def test_a_model_unknown_to_the_reference_and_every_online_chat_worker_is_404_with_no_row_and_no_tax(app, client, make_api_user) -> None:
|
|
from horde.model_reference import model_reference
|
|
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
unknown_model = f"custom/unknown-{uuid.uuid4().hex[:8]}"
|
|
assert not model_reference.is_known_text_model(unknown_model)
|
|
|
|
response = _post_chat(client, headers, _chat_body(unknown_model))
|
|
|
|
assert response.status_code == 404, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "UnknownChatModel"
|
|
assert _count_chat_requests(app, requester.id) == 0
|
|
assert _count_generation_debits(app, requester.id) == 0
|
|
|
|
|
|
def test_an_unknown_model_is_refused_after_authentication(client) -> None:
|
|
headers = {"apikey": "not-a-registered-key", "Client-Agent": "aihorde_ci_client:1:test"}
|
|
|
|
response = _post_chat(client, headers, _chat_body(f"custom/unknown-{uuid.uuid4().hex[:8]}"))
|
|
|
|
assert response.status_code == 401, response.get_data(as_text=True)
|
|
|
|
|
|
def test_an_unknown_model_is_refused_as_unknown_rather_than_as_a_context_that_cannot_grow(client, request_headers: dict[str, str]) -> None:
|
|
chat_body = _chat_body(f"custom/unknown-{uuid.uuid4().hex[:8]}", context_fit="reject")
|
|
chat_body["messages"] = [{"role": "user", "content": "word " * 1700}]
|
|
|
|
response = _post_chat(client, request_headers, chat_body)
|
|
|
|
assert response.status_code == 404, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "UnknownChatModel"
|
|
|
|
|
|
def _quote_kudos(client, headers: dict[str, str]) -> int:
|
|
"""Return the kudos a dry run of the default chat body quotes, the estimate ``horde.max_kudos`` is checked against."""
|
|
response = _post_chat(client, headers, _chat_body(dry_run=True))
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
# The response model marshals kudos as a number with a fraction, though the route rounds it to a whole one.
|
|
quoted_kudos = int(response.get_json()["kudos"])
|
|
assert quoted_kudos > 1
|
|
return quoted_kudos
|
|
|
|
|
|
def test_a_kudos_estimate_over_max_kudos_is_400_stating_both_with_no_row_and_no_tax(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
kudos_estimate = _quote_kudos(client, headers)
|
|
max_kudos = kudos_estimate - 1
|
|
|
|
response = _post_chat(client, headers, _chat_body(max_kudos=max_kudos))
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
refusal = response.get_json()
|
|
assert refusal["rc"] == "ChatKudosCapExceeded"
|
|
assert KudosCapExcess.read_from_message(refusal["message"]) == KudosCapExcess(kudos_estimate=kudos_estimate, max_kudos=max_kudos)
|
|
assert _count_chat_requests(app, requester.id) == 0
|
|
assert _count_generation_debits(app, requester.id) == 0
|
|
|
|
|
|
def test_a_kudos_estimate_at_max_kudos_is_queued(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
kudos_estimate = _quote_kudos(client, headers)
|
|
|
|
response = _post_chat(client, headers, _chat_body(max_kudos=kudos_estimate))
|
|
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
assert _count_chat_requests(app, requester.id) == 1
|
|
|
|
|
|
def test_a_dry_run_over_max_kudos_is_refused_as_its_submission_would_be(app, client, make_api_user) -> None:
|
|
"""A quote does not report a request that its submission would refuse."""
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
kudos_estimate = _quote_kudos(client, headers)
|
|
|
|
response = _post_chat(client, headers, _chat_body(dry_run=True, max_kudos=kudos_estimate - 1))
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "ChatKudosCapExceeded"
|
|
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()
|
|
# A worker is offered only the output its measured speed finishes inside the base lease, so a worker that is
|
|
# eligible for its whole advertised length needs a recorded speed to match: 8192 tokens in 120 s is about 69
|
|
# tokens per second.
|
|
_set_worker_speed(app, worker.name, tokens_per_second=80)
|
|
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"]
|
|
# The reservation and the dry-run quote are different figures for the same request, and the refusal states both.
|
|
figures = re.search(r"needs ([\d.]+) kudos held upfront; its quoted cost is ([\d.]+) kudos", refusal["message"])
|
|
assert figures is not None, refusal["message"]
|
|
reservation, quote = figures.groups()
|
|
assert float(reservation) > 0
|
|
assert float(quote) > 0
|
|
|
|
|
|
def test_a_quote_cached_for_a_funded_requester_does_not_answer_an_anonymous_text_dry_run_above_the_allowance(
|
|
client,
|
|
make_api_user,
|
|
settle_kudos,
|
|
) -> None:
|
|
"""The quote cache is keyed by the params alone, so it must not answer a dry run the requester would be refused.
|
|
|
|
No worker serves the model, so the allowance is at its floor, 2048 tokens is above it, and the anonymous dry run is
|
|
refused ``NoEligibleWorker`` in place of ``KudosUpfront``, without a declaration gap to state.
|
|
"""
|
|
funded = make_api_user(kudos=100000)
|
|
settle_kudos()
|
|
funded_headers = {"apikey": funded.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
text_body = {
|
|
"prompt": "Rivers.",
|
|
"params": {"max_length": 2048, "max_context_length": 8192},
|
|
"models": [f"koboldcpp/quote-cache-probe-{uuid.uuid4().hex[:8]}"],
|
|
"dry_run": True,
|
|
}
|
|
funded_quote = client.post("/api/v2/generate/text/async", json=text_body, headers=funded_headers)
|
|
assert funded_quote.status_code == 200, funded_quote.get_data(as_text=True)
|
|
|
|
anonymous_quote = client.post("/api/v2/generate/text/async", json=text_body, headers=ANONYMOUS_HEADERS)
|
|
|
|
assert anonymous_quote.status_code == 403, anonymous_quote.get_data(as_text=True)
|
|
assert anonymous_quote.get_json() == {
|
|
"message": "No online worker can serve this request.",
|
|
"rc": "NoEligibleWorker",
|
|
}
|
|
|
|
|
|
def test_a_repeated_operation_key_is_answered_while_admission_is_off(client, make_api_user, monkeypatch) -> None:
|
|
from horde.config import Config
|
|
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
chat_body = _chat_body(operation_key=str(uuid.uuid4()))
|
|
first_response = _post_chat(client, headers, chat_body)
|
|
assert first_response.status_code == 202, first_response.get_data(as_text=True)
|
|
monkeypatch.setattr(Config, "chat_admission_enabled", False)
|
|
|
|
repeated_response = _post_chat(client, headers, chat_body)
|
|
|
|
assert repeated_response.status_code == 200, repeated_response.get_data(as_text=True)
|
|
assert repeated_response.get_json()["id"] == first_response.get_json()["id"]
|
|
_cancel(client, headers, "chat", first_response.get_json()["id"])
|
|
|
|
|
|
def test_a_model_only_an_online_chat_worker_serves_is_accepted(client, request_headers: dict[str, str]) -> None:
|
|
from horde.model_reference import model_reference
|
|
|
|
custom_model, _ = _serve_custom_chat_model(client, request_headers)
|
|
assert not model_reference.is_known_text_model(custom_model)
|
|
|
|
response = _post_chat(client, request_headers, _chat_body(custom_model))
|
|
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
_cancel(client, request_headers, "chat", response.get_json()["id"])
|
|
|
|
|
|
def test_a_model_only_the_reference_lists_is_accepted_and_reported_as_not_possible(client, request_headers: dict[str, str]) -> None:
|
|
from horde.model_reference import model_reference
|
|
|
|
unserved_model = next(
|
|
model_name for model_name in sorted(model_reference.text_model_names) if model_name != CHAT_MODEL and "::" not in model_name
|
|
)
|
|
|
|
response = _post_chat(client, request_headers, _chat_body(unserved_model))
|
|
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
request_id = response.get_json()["id"]
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["is_possible"] is False
|
|
assert status["impossible_reason"] is None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_repeated_operation_key_returns_the_first_request_after_its_model_went_offline(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
custom_model, custom_worker = _serve_custom_chat_model(client, headers)
|
|
operation_key = str(uuid.uuid4())
|
|
|
|
first_response = _post_chat(client, headers, _chat_body(custom_model, operation_key=operation_key))
|
|
assert first_response.status_code == 202, first_response.get_data(as_text=True)
|
|
first_submission = first_response.get_json()
|
|
_take_worker_offline(app, custom_worker.name)
|
|
assert _post_chat(client, headers, _chat_body(custom_model)).status_code == 404
|
|
|
|
# max_completion_tokens in place of max_tokens is the same effective request.
|
|
repeated_body = _chat_body(custom_model, operation_key=operation_key)
|
|
repeated_body["max_completion_tokens"] = repeated_body.pop("max_tokens")
|
|
repeated_response = _post_chat(client, headers, repeated_body)
|
|
|
|
assert repeated_response.status_code == 200, repeated_response.get_data(as_text=True)
|
|
assert repeated_response.get_json() == {"id": first_submission["id"], "kudos": first_submission["kudos"]}
|
|
assert _count_chat_requests(app, requester.id) == 1
|
|
assert _count_generation_debits(app, requester.id) == 1
|
|
_cancel(client, headers, "chat", first_submission["id"])
|
|
|
|
|
|
def test_a_repeated_operation_key_with_a_changed_request_is_refused(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
operation_key = str(uuid.uuid4())
|
|
first_response = _post_chat(client, headers, _chat_body(operation_key=operation_key))
|
|
assert first_response.status_code == 202, first_response.get_data(as_text=True)
|
|
|
|
changed_body = _chat_body(operation_key=operation_key)
|
|
changed_body["temperature"] = 0.2
|
|
refused = _post_chat(client, headers, changed_body)
|
|
|
|
assert refused.status_code == 400, refused.get_data(as_text=True)
|
|
assert refused.get_json()["rc"] == "OperationKeyConflict"
|
|
assert _count_chat_requests(app, requester.id) == 1
|
|
_cancel(client, headers, "chat", first_response.get_json()["id"])
|
|
|
|
|
|
def _listing_body(models: list[str], **horde_options: Any) -> dict[str, Any]:
|
|
"""Return the default chat body with ``models`` in place of ``model``."""
|
|
chat_body = _chat_body(**horde_options)
|
|
del chat_body["model"]
|
|
chat_body["models"] = models
|
|
return chat_body
|
|
|
|
|
|
def test_a_request_listing_models_is_served_by_a_worker_of_one_and_reports_the_served_model(client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
custom_model, custom_worker = _serve_custom_chat_model(client, headers)
|
|
response = _post_chat(client, headers, _listing_body([CHAT_MODEL, custom_model]))
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
request_id = response.get_json()["id"]
|
|
|
|
pop_result = custom_worker.pop()
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["model"] == custom_model
|
|
submit_response = custom_worker.submit(build_chat_submit_body(generation_id=pop_result["id"], content="Blue.", model_id=custom_model))
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
[generation] = _chat_status(client, headers, request_id)["generations"]
|
|
assert generation["model"] == custom_model
|
|
|
|
|
|
def test_a_dry_run_of_a_request_listing_models_resolves_every_listed_model(client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
custom_model, _ = _serve_custom_chat_model(client, headers)
|
|
|
|
response = _post_chat(client, headers, _listing_body([CHAT_MODEL, custom_model], dry_run=True))
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
assert response.get_json()["resolved"]["models"] == [CHAT_MODEL, custom_model]
|
|
|
|
|
|
def test_an_unknown_entry_of_models_refuses_the_whole_request_at_its_index(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
unknown_model = f"custom/unknown-{uuid.uuid4().hex[:8]}"
|
|
|
|
response = _post_chat(client, headers, _listing_body([CHAT_MODEL, unknown_model]))
|
|
|
|
assert response.status_code == 404, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "UnknownChatModel"
|
|
assert response.get_json()["message"].startswith(f"'models[1]': The model '{unknown_model}'")
|
|
assert _count_chat_requests(app, requester.id) == 0
|
|
assert _count_generation_debits(app, requester.id) == 0
|
|
|
|
|
|
def test_a_repeated_operation_key_with_the_models_reordered_returns_the_first_request(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
custom_model, _ = _serve_custom_chat_model(client, headers)
|
|
operation_key = str(uuid.uuid4())
|
|
first_response = _post_chat(client, headers, _listing_body([CHAT_MODEL, custom_model], operation_key=operation_key))
|
|
assert first_response.status_code == 202, first_response.get_data(as_text=True)
|
|
first_submission = first_response.get_json()
|
|
|
|
repeated_response = _post_chat(client, headers, _listing_body([custom_model, CHAT_MODEL], operation_key=operation_key))
|
|
|
|
assert repeated_response.status_code == 200, repeated_response.get_data(as_text=True)
|
|
assert repeated_response.get_json() == {"id": first_submission["id"], "kudos": first_submission["kudos"]}
|
|
assert _count_chat_requests(app, requester.id) == 1
|
|
_cancel(client, headers, "chat", first_submission["id"])
|
|
|
|
|
|
def test_one_operation_key_from_two_users_creates_two_requests(client, make_api_user) -> None:
|
|
operation_key = str(uuid.uuid4())
|
|
request_ids = []
|
|
for _ in range(2):
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
response = _post_chat(client, headers, _chat_body(operation_key=operation_key))
|
|
assert response.status_code == 202, response.get_data(as_text=True)
|
|
request_ids.append(response.get_json()["id"])
|
|
_cancel(client, headers, "chat", request_ids[-1])
|
|
|
|
assert request_ids[0] != request_ids[1]
|
|
|
|
|
|
def test_a_dry_run_with_an_operation_key_quotes_without_claiming_it(app, client, make_api_user) -> None:
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
operation_key = str(uuid.uuid4())
|
|
|
|
quote = _post_chat(client, headers, _chat_body(operation_key=operation_key, dry_run=True))
|
|
assert quote.status_code == 200, quote.get_data(as_text=True)
|
|
assert "id" not in quote.get_json()
|
|
|
|
queued = _post_chat(client, headers, _chat_body(operation_key=operation_key))
|
|
assert queued.status_code == 202, queued.get_data(as_text=True)
|
|
assert _count_chat_requests(app, requester.id) == 1
|
|
_cancel(client, headers, "chat", queued.get_json()["id"])
|
|
|
|
|
|
def test_two_concurrent_submits_with_one_operation_key_make_one_activation_one_tax_and_one_job(
|
|
app,
|
|
client,
|
|
make_api_user,
|
|
monkeypatch,
|
|
) -> None:
|
|
import threading
|
|
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
|
|
requester = make_api_user()
|
|
headers = {"apikey": requester.api_key, "Client-Agent": "aihorde_ci_client:1:test"}
|
|
chat_body = _chat_body(operation_key=str(uuid.uuid4()))
|
|
|
|
# Both submissions pass the claim lookup before either activates, so only the unique index can tell them apart.
|
|
both_activating = threading.Barrier(2, timeout=30)
|
|
waited_threads: set[int] = set()
|
|
original_activate = TextWaitingPrompt._activate
|
|
|
|
def activate_together(waiting_prompt: TextWaitingPrompt, *args: Any, **kwargs: Any) -> None:
|
|
if threading.get_ident() not in waited_threads:
|
|
waited_threads.add(threading.get_ident())
|
|
both_activating.wait()
|
|
original_activate(waiting_prompt, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(TextWaitingPrompt, "_activate", activate_together)
|
|
responses: list[Any] = []
|
|
|
|
def submit() -> None:
|
|
# Each thread pushes an application context of its own, and so works on a database session of its own.
|
|
with app.app_context():
|
|
responses.append(_post_chat(client, headers, chat_body))
|
|
|
|
submitters = [threading.Thread(target=submit) for _ in range(2)]
|
|
for submitter in submitters:
|
|
submitter.start()
|
|
for submitter in submitters:
|
|
submitter.join(timeout=60)
|
|
|
|
assert sorted(response.status_code for response in responses) == [200, 202], [response.get_data(as_text=True) for response in responses]
|
|
assert responses[0].get_json()["id"] == responses[1].get_json()["id"]
|
|
assert _count_chat_requests(app, requester.id) == 1
|
|
assert _count_generation_debits(app, requester.id) == 1
|
|
_cancel(client, headers, "chat", responses[0].get_json()["id"])
|
|
|
|
|
|
def test_a_body_with_a_length_above_the_limit_is_refused_before_it_is_parsed(client, request_headers: dict[str, str]) -> None:
|
|
from horde.chat_contract import MAX_CHAT_BODY_BYTES
|
|
|
|
response = client.post(
|
|
"/api/v2/generate/chat/async",
|
|
data=b"{" * (MAX_CHAT_BODY_BYTES + 1),
|
|
content_type="application/json",
|
|
headers=request_headers,
|
|
)
|
|
|
|
assert response.status_code == 413, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "RequestTooLarge"
|
|
|
|
|
|
def test_a_chunked_body_above_the_limit_is_refused(client, request_headers: dict[str, str]) -> None:
|
|
import io
|
|
|
|
from horde.chat_contract import MAX_CHAT_BODY_BYTES
|
|
|
|
response = client.post(
|
|
"/api/v2/generate/chat/async",
|
|
input_stream=io.BytesIO(b"{" * (MAX_CHAT_BODY_BYTES + 1)),
|
|
content_type="application/json",
|
|
headers=request_headers,
|
|
)
|
|
|
|
assert response.status_code == 413, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "RequestTooLarge"
|
|
|
|
|
|
def test_a_chunked_body_within_the_limit_is_parsed(client, request_headers: dict[str, str]) -> None:
|
|
import io
|
|
|
|
response = client.post(
|
|
"/api/v2/generate/chat/async",
|
|
input_stream=io.BytesIO(json.dumps(_chat_body(dry_run=True)).encode()),
|
|
content_type="application/json",
|
|
headers=request_headers,
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
assert response.get_json()["kudos"] > 0
|
|
|
|
|
|
@pytest.mark.parametrize("chunked", [False, True], ids=["content_length", "chunked"])
|
|
def test_a_body_holding_a_non_json_constant_is_refused_like_a_non_json_body(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chunked: bool,
|
|
) -> None:
|
|
"""``NaN`` and ``Infinity`` are not JSON; a body holding one is refused the same way as a body that is not JSON."""
|
|
import io
|
|
|
|
body = json.dumps(_chat_body(dry_run=True, temperature=1.0)).replace("1.0", "NaN").encode()
|
|
post_kwargs = {"input_stream": io.BytesIO(body)} if chunked else {"data": body}
|
|
|
|
not_json = client.post("/api/v2/generate/chat/async", data=b"not json", content_type="application/json", headers=request_headers)
|
|
response = client.post("/api/v2/generate/chat/async", content_type="application/json", headers=request_headers, **post_kwargs)
|
|
|
|
assert not_json.status_code == 400, not_json.get_data(as_text=True)
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == not_json.get_json()["rc"]
|
|
|
|
|
|
def test_a_grown_context_is_reported_on_the_chat_status(client, request_headers: dict[str, str], chat_worker: FakeChatWorker) -> None:
|
|
chat_worker.pop()
|
|
long_messages = [{"role": "user", "content": "word " * 1700}]
|
|
|
|
grown_id = _submit_chat_request(client, request_headers, messages=long_messages, max_tokens=64)
|
|
fitting_id = _submit_chat_request(client, request_headers, max_tokens=64)
|
|
|
|
[adjustment] = _chat_status(client, request_headers, grown_id)["context_adjustments"]
|
|
assert adjustment["from"] == 2048
|
|
assert 2048 < adjustment["to"] <= 8192
|
|
assert adjustment["reason"] == "grow"
|
|
assert _chat_status(client, request_headers, fitting_id)["context_adjustments"] is None
|
|
_cancel(client, request_headers, "chat", grown_id)
|
|
_cancel(client, request_headers, "chat", fitting_id)
|
|
|
|
|
|
LONG_HISTORY_SYSTEM = {"role": "system", "content": "You are a terse assistant."}
|
|
LONG_HISTORY_LATEST_USER = {"role": "user", "content": "And which colour comes last?"}
|
|
LONG_HISTORY = [
|
|
LONG_HISTORY_SYSTEM,
|
|
*(
|
|
message
|
|
for turn in range(8)
|
|
for message in (
|
|
{"role": "user", "content": f"Turn {turn}: " + "tell me about colours " * 200},
|
|
{"role": "assistant", "content": f"Reply {turn}: " + "red green blue " * 200},
|
|
)
|
|
),
|
|
LONG_HISTORY_LATEST_USER,
|
|
]
|
|
"""A history whose estimate is far above any context the fake worker serves, while its latest turn is short."""
|
|
|
|
|
|
def test_a_long_history_under_truncate_oldest_is_admitted_with_its_oldest_turns_dropped(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
chat_worker.pop()
|
|
|
|
request_id = _submit_chat_request(
|
|
client,
|
|
request_headers,
|
|
messages=LONG_HISTORY,
|
|
max_tokens=64,
|
|
horde={"max_context_length": 2048, "context_fit": "truncate_oldest"},
|
|
)
|
|
|
|
grow_adjustment, truncate_adjustment = _chat_status(client, request_headers, request_id)["context_adjustments"]
|
|
assert grow_adjustment["reason"] == "grow"
|
|
assert grow_adjustment["from"] == 2048
|
|
assert 2048 < grow_adjustment["to"] <= 8192
|
|
assert truncate_adjustment["reason"] == "truncate_oldest"
|
|
assert truncate_adjustment["dropped_messages"] > 0
|
|
assert truncate_adjustment["dropped_messages"] % 2 == 0
|
|
assert truncate_adjustment["from"] > grow_adjustment["to"]
|
|
assert truncate_adjustment["to"] + 64 <= grow_adjustment["to"]
|
|
|
|
popped = chat_worker.pop()
|
|
assert popped["id"] is not None, popped
|
|
kept_messages = popped["payload"]["messages"]
|
|
assert popped["payload"]["max_context_length"] == grow_adjustment["to"]
|
|
assert len(kept_messages) == len(LONG_HISTORY) - truncate_adjustment["dropped_messages"]
|
|
assert kept_messages[0] == LONG_HISTORY_SYSTEM
|
|
assert kept_messages[-1] == LONG_HISTORY_LATEST_USER
|
|
assert kept_messages[1:] == LONG_HISTORY[1 + truncate_adjustment["dropped_messages"] :]
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_long_history_under_reject_is_refused(client, request_headers: dict[str, str], chat_worker: FakeChatWorker) -> None:
|
|
chat_worker.pop()
|
|
|
|
response = client.post(
|
|
"/api/v2/generate/chat/async",
|
|
json={
|
|
"model": CHAT_MODEL,
|
|
"messages": LONG_HISTORY,
|
|
"max_tokens": 64,
|
|
"horde": {"max_context_length": 2048, "context_fit": "reject"},
|
|
},
|
|
headers=request_headers,
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "PromptExceedsContext"
|
|
|
|
|
|
def test_a_latest_turn_too_long_for_any_context_is_refused_under_truncate_oldest(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
chat_worker.pop()
|
|
overlong_latest_user = {"role": "user", "content": "describe every colour " * 2000}
|
|
|
|
response = client.post(
|
|
"/api/v2/generate/chat/async",
|
|
json={
|
|
"model": CHAT_MODEL,
|
|
"messages": [*LONG_HISTORY[:-1], overlong_latest_user],
|
|
"max_tokens": 64,
|
|
"horde": {"max_context_length": 2048, "context_fit": "truncate_oldest"},
|
|
},
|
|
headers=request_headers,
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "PromptExceedsContext"
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["temperature", "top_p", "frequency_penalty", "presence_penalty"])
|
|
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")])
|
|
def test_native_http_refuses_nonfinite_sampling_values(client, request_headers: dict[str, str], field: str, value: float) -> None:
|
|
body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, field: value}
|
|
response = client.post("/api/v2/generate/chat/async", data=json.dumps(body), content_type="application/json", headers=request_headers)
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "BadRequest"
|
|
|
|
|
|
@pytest.mark.parametrize(("content", "reason"), [("Blue.\x00", "eos"), ("Blue.", "eos\x00")])
|
|
def test_worker_nuls_are_sanitised_in_the_atomic_structured_submission(
|
|
client, request_headers: dict[str, str], chat_worker: FakeChatWorker, content: str, reason: str
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
popped = chat_worker.pop()
|
|
response = chat_worker.submit(
|
|
build_chat_submit_body(generation_id=popped["id"], content=content, model_id=CHAT_MODEL, native_finish_reason=reason)
|
|
)
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
assert response.get_json()["reward"] > 0
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["done"]
|
|
[generation] = status["generations"]
|
|
assert generation["text"] == generation["message"]["content"] == content.replace("\x00", "\ufffd")
|
|
assert generation["native_finish_reason"] == reason.replace("\x00", "\ufffd")
|
|
duplicate = chat_worker.submit(
|
|
build_chat_submit_body(generation_id=popped["id"], content=content, model_id=CHAT_MODEL, native_finish_reason=reason),
|
|
)
|
|
assert duplicate.status_code == 400
|
|
assert duplicate.get_json()["rc"] == "DuplicateGen"
|
|
assert "reward" not in duplicate.get_json()
|
|
after_duplicate = _chat_status(client, request_headers, request_id)
|
|
assert after_duplicate["generations"] == status["generations"]
|
|
assert after_duplicate["kudos"] == status["kudos"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overrides",
|
|
[
|
|
{"messages": [{"role": "user", "content": "Blue\x00"}]},
|
|
{"messages": [{"role": "user", "content": [{"type": "text", "text": "Blue\x00"}]}]},
|
|
{"messages": [{"role": "user", "content": "Blue", "name": "name\x00"}]},
|
|
{"stop": "stop\x00"},
|
|
{"tools": [{"type": "function", "function": {"name": "lookup", "description": "Look\x00"}}]},
|
|
{"tools": [{"type": "function", "function": {"name": "lookup", "parameters": {"properties": {"bad\x00key": {}}}}}]},
|
|
{"response_format": {"type": "json_schema", "json_schema": {"name": "n", "schema": {"properties": {"bad\x00key": {}}}}}},
|
|
],
|
|
)
|
|
def test_client_nuls_are_refused_before_storage_and_leave_the_session_usable(
|
|
app, client, request_headers: dict[str, str], overrides: dict[str, Any]
|
|
) -> None:
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
before = db.session.query(TextWaitingPrompt).count()
|
|
body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, **overrides}
|
|
response = client.post("/api/v2/generate/chat/async", json=body, headers=request_headers)
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "NulByteInPayload"
|
|
with app.app_context():
|
|
assert db.session.query(TextWaitingPrompt).count() == before
|
|
valid_id = _submit_chat_request(client, request_headers)
|
|
_cancel(client, request_headers, "chat", valid_id)
|
|
|
|
|
|
LOOKUP_TOOL: dict[str, Any] = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup_weather",
|
|
"description": "Look up the weather.",
|
|
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
|
},
|
|
}
|
|
"""A function tool as an OpenAI client sends it."""
|
|
|
|
TOOL_HISTORY: list[dict[str, Any]] = [
|
|
{"role": "user", "content": "Weather in Oslo, then Bergen?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [{"id": "call_oslo", "type": "function", "function": {"name": "lookup_weather", "arguments": '{"city":"Oslo"}'}}],
|
|
},
|
|
{"role": "tool", "content": "Rain, 7C", "tool_call_id": "call_oslo"},
|
|
]
|
|
"""A conversation in which the model called a tool once and the client answered it."""
|
|
|
|
TOOLS_REQUEST_EXTRAS: dict[str, Any] = {
|
|
"tools": [LOOKUP_TOOL],
|
|
"tool_choice": "auto",
|
|
"parallel_tool_calls": False,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
"""The four extra keys of a tools request, as the client sends them and as the worker receives them."""
|
|
|
|
LONG_ARGUMENTS = '{"city":"Bergen","detail":"' + "d" * 160 + '"}'
|
|
"""Tool call arguments long enough that their characters, and not the minimum, set the output estimate."""
|
|
|
|
TOOLS_FEATURES = ["tools", "json_object", "tools_with_response_format"]
|
|
"""The features a worker declares to serve ``TOOLS_REQUEST_EXTRAS``."""
|
|
|
|
|
|
def test_a_tools_request_waits_for_a_worker_declaring_tools_and_is_not_possible_without_one(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
assert chat_worker.pop(chat_features=["json_object"])["id"] is None
|
|
request_id = _submit_chat_request(client, request_headers, messages=TOOL_HISTORY, **TOOLS_REQUEST_EXTRAS)
|
|
|
|
assert chat_worker.pop(chat_features=["json_object"])["id"] is None
|
|
assert _chat_status(client, request_headers, request_id)["is_possible"] is False
|
|
assert chat_worker.pop(chat_features=TOOLS_FEATURES)["id"] is not None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_tool_history_alone_needs_a_worker_declaring_tools(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, messages=TOOL_HISTORY)
|
|
|
|
assert chat_worker.pop(chat_features=[])["id"] is None
|
|
pop_result = chat_worker.pop(chat_features=["tools"])
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert not {"tools", "tool_choice", "parallel_tool_calls", "response_format"} & set(pop_result["payload"])
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_tool_call_reply_is_popped_submitted_shown_and_charged_by_its_arguments(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
monkeypatch,
|
|
) -> None:
|
|
from horde.classes.kobold import processing_generation
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
empty_generation_counts: list[dict[str, str]] = []
|
|
|
|
class RecordingCounter:
|
|
def add(self, amount: int, attributes: dict[str, str]) -> None:
|
|
empty_generation_counts.append(attributes)
|
|
|
|
monkeypatch.setattr(processing_generation, "text_empty_generations", RecordingCounter())
|
|
request_id = _submit_chat_request(client, request_headers, messages=TOOL_HISTORY, max_tokens=80, **TOOLS_REQUEST_EXTRAS)
|
|
|
|
pop_result = chat_worker.pop(chat_features=TOOLS_FEATURES)
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["payload"]["messages"] == TOOL_HISTORY
|
|
assert {key: pop_result["payload"][key] for key in TOOLS_REQUEST_EXTRAS} == TOOLS_REQUEST_EXTRAS
|
|
tool_call = {"id": "call_bergen", "type": "function", "function": {"name": "lookup_weather", "arguments": LONG_ARGUMENTS}}
|
|
submit_response = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=pop_result["id"],
|
|
content=None,
|
|
tool_calls=[tool_call],
|
|
finish_reason="tool_calls",
|
|
model_id=CHAT_MODEL,
|
|
),
|
|
)
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["done"] is True
|
|
[generation] = status["generations"]
|
|
assert generation["message"] == {"role": "assistant", "content": None, "tool_calls": [tool_call]}
|
|
assert generation["finish_reason"] == "tool_calls"
|
|
assert generation["text"] == ""
|
|
assert empty_generation_counts == []
|
|
|
|
with app.app_context():
|
|
flattened_prompt = db.session.get(TextWaitingPrompt, uuid.UUID(request_id)).prompt
|
|
assert "tool: Rain, 7C" in flattened_prompt
|
|
assert '"call_oslo"' in flattened_prompt
|
|
assert '"lookup_weather","description"' in flattened_prompt
|
|
_submit_text_request(client, request_headers, prompt=flattened_prompt)
|
|
text_pop = chat_worker.pop()
|
|
assert text_pop["id"] is not None, text_pop
|
|
charged_characters = "x" * (len("lookup_weather") + len(LONG_ARGUMENTS))
|
|
text_submit = chat_worker.submit({"id": text_pop["id"], "generation": charged_characters, "state": "ok", "seed": 0})
|
|
assert text_submit.status_code == 200, text_submit.get_data(as_text=True)
|
|
assert submit_response.get_json()["reward"] == text_submit.get_json()["reward"]
|
|
|
|
|
|
def test_a_json_schema_request_goes_only_to_a_worker_declaring_json_schema(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
response_format = {
|
|
"type": "json_schema",
|
|
"json_schema": {"name": "colour", "schema": {"type": "object", "properties": {"name": {"type": "string"}}}},
|
|
}
|
|
request_id = _submit_chat_request(client, request_headers, response_format=response_format)
|
|
|
|
assert chat_worker.pop(chat_features=["json_object", "tools"])["id"] is None
|
|
pop_result = chat_worker.pop(chat_features=["json_schema"])
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["payload"]["response_format"] == response_format
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"chat_body_overrides",
|
|
[
|
|
{"tool_choice": "auto"},
|
|
{"tool_choice": "none"},
|
|
{"parallel_tool_calls": False},
|
|
{"tools": [LOOKUP_TOOL], "tool_choice": {"type": "function", "function": {"name": "send_email"}}},
|
|
],
|
|
ids=["tool-choice-without-tools", "tool-choice-none-without-tools", "parallel-tool-calls-without-tools", "unknown-forced-function"],
|
|
)
|
|
def test_a_tool_choice_the_tools_do_not_allow_is_a_conflict(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_body_overrides: dict[str, Any],
|
|
) -> None:
|
|
chat_body = {"model": CHAT_MODEL, "messages": CHAT_MESSAGES, **chat_body_overrides}
|
|
|
|
response = client.post("/api/v2/generate/chat/async", json=chat_body, headers=request_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatRequestConflict"
|
|
|
|
|
|
EMAIL_TOOL: dict[str, Any] = {
|
|
"type": "function",
|
|
"function": {"name": "send_email", "parameters": {"type": "object", "properties": {"to": {"type": "string"}}}},
|
|
}
|
|
"""A second function tool, beside ``LOOKUP_TOOL``."""
|
|
|
|
NAMED_LOOKUP_CHOICE: dict[str, Any] = {"type": "function", "function": {"name": "lookup_weather"}}
|
|
"""A ``tool_choice`` forcing ``LOOKUP_TOOL``."""
|
|
|
|
TOOL_KEYS = frozenset(
|
|
{
|
|
"tools",
|
|
"tool_choice",
|
|
"parallel_tool_calls",
|
|
},
|
|
)
|
|
"""The payload keys that carry tool definitions and constraints."""
|
|
|
|
|
|
def _stored_chat_declaration(app, worker_name: str) -> tuple[list[str], float | None]:
|
|
from horde.classes.kobold.worker import TextWorker
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
stored_row = db.session.query(TextWorker.chat_features, TextWorker.chat_tools_max_temperature).filter_by(name=worker_name).one()
|
|
return list(stored_row.chat_features or ()), stored_row.chat_tools_max_temperature
|
|
|
|
|
|
def test_a_pop_stores_declared_guarantees_beside_features_and_the_tool_temperature_limit(app, chat_worker: FakeChatWorker) -> None:
|
|
chat_worker.pop(chat_features=["tools", "not_a_known_name"], chat_guarantees=["tool_choice_enforced"], tools_max_temperature=1.0)
|
|
|
|
assert _stored_chat_declaration(app, WORKER_NAME) == (["tool_choice_enforced", "tools"], 1.0)
|
|
|
|
chat_worker.pop(chat_features=["tools"], chat_guarantees=[])
|
|
|
|
assert _stored_chat_declaration(app, WORKER_NAME) == (["tools"], None)
|
|
|
|
|
|
def test_a_negative_tool_temperature_limit_is_refused_on_pop(chat_worker: FakeChatWorker) -> None:
|
|
response = chat_worker.pop_response(tools_max_temperature=-0.5)
|
|
|
|
assert response.status_code == 400
|
|
assert response.get_json()["rc"] == "ChatDeclarationInvalid"
|
|
|
|
|
|
def test_a_strict_named_tool_choice_waits_for_a_worker_enforcing_tool_choice_and_receives_only_that_tool(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, tools=[EMAIL_TOOL, LOOKUP_TOOL], tool_choice=NAMED_LOOKUP_CHOICE)
|
|
|
|
assert chat_worker.pop(chat_features=["tools"], chat_guarantees=["prompt_overflow_refused"])["id"] is None
|
|
pop_result = chat_worker.pop(chat_features=["tools"])
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["payload"]["tools"] == [LOOKUP_TOOL]
|
|
assert pop_result["payload"]["tool_choice"] == "required"
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_best_effort_named_tool_choice_is_served_by_a_worker_without_guarantees(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(
|
|
client,
|
|
request_headers,
|
|
tools=[EMAIL_TOOL, LOOKUP_TOOL],
|
|
tool_choice=NAMED_LOOKUP_CHOICE,
|
|
horde={"conformance": "best_effort"},
|
|
)
|
|
|
|
pop_result = chat_worker.pop(chat_features=["tools"], chat_guarantees=[])
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["payload"]["tools"] == [LOOKUP_TOOL]
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_tool_choice_none_forwards_no_tool_fields(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, tools=[LOOKUP_TOOL], tool_choice="none", parallel_tool_calls=False)
|
|
|
|
pop_result = chat_worker.pop(chat_features=[], chat_guarantees=["prompt_overflow_refused"])
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert not TOOL_KEYS & set(pop_result["payload"])
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_tool_request_hotter_than_a_worker_tool_temperature_limit_waits_for_another_worker(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, tools=[LOOKUP_TOOL], temperature=1.5)
|
|
|
|
assert chat_worker.pop(chat_features=["tools"], tools_max_temperature=1.0)["id"] is None
|
|
assert chat_worker.pop(chat_features=["tools"])["id"] is not None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_strict_named_tool_choice_no_online_worker_enforces_reports_the_missing_guarantee(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, tools=[EMAIL_TOOL, LOOKUP_TOOL], tool_choice=NAMED_LOOKUP_CHOICE)
|
|
|
|
assert chat_worker.pop(chat_features=["tools"], chat_guarantees=["prompt_overflow_refused"])["id"] is None
|
|
status = _chat_status(client, request_headers, request_id)
|
|
|
|
assert status["is_possible"] is False
|
|
assert status["impossible_reason"] == {
|
|
"kind": "declaration_gap",
|
|
"missing_declarations": ["tool_choice_enforced"],
|
|
"exceeded_limits": [],
|
|
}
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_tool_request_hotter_than_every_worker_tool_temperature_limit_reports_the_limit(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, tools=[LOOKUP_TOOL], temperature=1.5)
|
|
|
|
assert chat_worker.pop(chat_features=["tools"], tools_max_temperature=1.0)["id"] is None
|
|
status = _chat_status(client, request_headers, request_id)
|
|
|
|
assert status["is_possible"] is False
|
|
assert status["impossible_reason"] == {
|
|
"kind": "declaration_gap",
|
|
"missing_declarations": [],
|
|
"exceeded_limits": ["tools_max_temperature"],
|
|
}
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_request_over_every_worker_chat_output_limit_reports_the_limit(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, max_tokens=400)
|
|
|
|
assert chat_worker.pop(chat_max_length=300)["id"] is None
|
|
status = _chat_status(client, request_headers, request_id)
|
|
|
|
assert status["is_possible"] is False
|
|
assert status["impossible_reason"] == {
|
|
"kind": "declaration_gap",
|
|
"missing_declarations": [],
|
|
"exceeded_limits": ["max_length"],
|
|
}
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
@pytest.mark.parametrize("conformance", ["strict", "best_effort"])
|
|
def test_tools_beside_a_response_format_without_a_worker_serving_the_combination_report_the_gap(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
conformance: str,
|
|
) -> None:
|
|
request_id = _submit_chat_request(
|
|
client,
|
|
request_headers,
|
|
tools=[LOOKUP_TOOL],
|
|
response_format={"type": "json_object"},
|
|
horde={"conformance": conformance},
|
|
)
|
|
|
|
worker_guarantees = ["prompt_overflow_refused"]
|
|
assert chat_worker.pop(chat_features=["tools", "json_object"], chat_guarantees=worker_guarantees)["id"] is None
|
|
status = _chat_status(client, request_headers, request_id)
|
|
|
|
assert status["is_possible"] is False
|
|
assert status["impossible_reason"] == {
|
|
"kind": "declaration_gap",
|
|
"missing_declarations": ["tools_with_response_format"],
|
|
"exceeded_limits": [],
|
|
}
|
|
assert chat_worker.pop(chat_features=TOOLS_FEATURES, chat_guarantees=worker_guarantees)["id"] is not None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_request_a_worker_can_take_reports_no_impossible_reason(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
chat_worker.pop(chat_features=["tools"])
|
|
request_id = _submit_chat_request(client, request_headers, tools=[LOOKUP_TOOL])
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
|
|
assert status["is_possible"] is True
|
|
assert status["impossible_reason"] is None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
VERIFIED_FEATURES = ["tools", "json_object", "json_schema", "tools_with_response_format"]
|
|
"""The features a worker declares to pop every request the reply verification tests make."""
|
|
|
|
COLOUR_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
"required": ["name"],
|
|
"additionalProperties": False,
|
|
}
|
|
"""A JSON Schema for one named colour."""
|
|
|
|
|
|
def _colour_format(*, strict: bool) -> dict[str, Any]:
|
|
return {"type": "json_schema", "json_schema": {"name": "colour", "schema": COLOUR_SCHEMA, "strict": strict}}
|
|
|
|
|
|
def _tool_call(name: str = "lookup_weather", call_id: str = "call_one") -> dict[str, Any]:
|
|
return {"id": call_id, "type": "function", "function": {"name": name, "arguments": '{"city":"Bergen"}'}}
|
|
|
|
|
|
def _serve_reply(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
request_overrides: dict[str, Any],
|
|
*,
|
|
content: str | None,
|
|
tool_calls: list[dict[str, Any]] | None = None,
|
|
) -> tuple[str, Any]:
|
|
"""Queue a chat request, pop it with every feature and guarantee declared, and submit one reply to it."""
|
|
request_id = _submit_chat_request(client, request_headers, **request_overrides)
|
|
pop_result = chat_worker.pop(chat_features=VERIFIED_FEATURES)
|
|
assert pop_result["id"] is not None, pop_result
|
|
submit_response = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=pop_result["id"],
|
|
content=content,
|
|
tool_calls=tool_calls,
|
|
finish_reason="tool_calls" if tool_calls else "stop",
|
|
model_id=CHAT_MODEL,
|
|
),
|
|
)
|
|
return request_id, submit_response
|
|
|
|
|
|
NONCONFORMANT_REPLIES = [
|
|
pytest.param({"tools": [LOOKUP_TOOL], "tool_choice": "none"}, None, [_tool_call()], id="none-with-call"),
|
|
pytest.param({"tools": [LOOKUP_TOOL], "tool_choice": "required"}, "Sunny.", None, id="required-without-call"),
|
|
pytest.param({"tools": [EMAIL_TOOL, LOOKUP_TOOL], "tool_choice": NAMED_LOOKUP_CHOICE}, "Sunny.", None, id="named-without-call"),
|
|
pytest.param(
|
|
{"tools": [EMAIL_TOOL, LOOKUP_TOOL], "tool_choice": NAMED_LOOKUP_CHOICE},
|
|
None,
|
|
[_tool_call("send_email")],
|
|
id="named-other-function",
|
|
),
|
|
pytest.param({"response_format": {"type": "json_object"}}, "Blue.", None, id="json-object-not-json"),
|
|
pytest.param({"response_format": _colour_format(strict=True)}, '{"name": 7}', None, id="strict-schema-mismatch"),
|
|
]
|
|
"""Requests with a reply each that the request's ``tool_choice`` or ``response_format`` rules out."""
|
|
|
|
|
|
@pytest.mark.parametrize(("request_overrides", "content", "tool_calls"), NONCONFORMANT_REPLIES)
|
|
def test_a_strict_request_refuses_a_reply_its_tool_choice_or_response_format_rules_out(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
request_overrides: dict[str, Any],
|
|
content: str | None,
|
|
tool_calls: list[dict[str, Any]] | None,
|
|
) -> None:
|
|
request_id, submit_response = _serve_reply(
|
|
client, request_headers, chat_worker, request_overrides, content=content, tool_calls=tool_calls
|
|
)
|
|
|
|
assert submit_response.status_code == 400
|
|
assert submit_response.get_json()["rc"] == "ChatSubmitNonconformant"
|
|
assert _chat_status(client, request_headers, request_id)["done"] is False
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
@pytest.mark.parametrize(("request_overrides", "content", "tool_calls"), NONCONFORMANT_REPLIES)
|
|
def test_a_best_effort_request_shows_that_reply_as_submitted(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
request_overrides: dict[str, Any],
|
|
content: str | None,
|
|
tool_calls: list[dict[str, Any]] | None,
|
|
) -> None:
|
|
best_effort_overrides = {**request_overrides, "horde": {"conformance": "best_effort"}}
|
|
|
|
request_id, submit_response = _serve_reply(
|
|
client, request_headers, chat_worker, best_effort_overrides, content=content, tool_calls=tool_calls
|
|
)
|
|
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
[generation] = _chat_status(client, request_headers, request_id)["generations"]
|
|
expected_message: dict[str, Any] = {"role": "assistant", "content": content}
|
|
if tool_calls is not None:
|
|
expected_message["tool_calls"] = tool_calls
|
|
assert generation["message"] == expected_message
|
|
|
|
|
|
@pytest.mark.parametrize("conformance", ["strict", "best_effort"])
|
|
@pytest.mark.parametrize(
|
|
"tool_calls",
|
|
[[_tool_call("send_email")], [_tool_call(), _tool_call()]],
|
|
ids=["function-not-offered", "repeated-call-id"],
|
|
)
|
|
def test_a_reply_no_client_could_use_is_refused_under_every_conformance_mode(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
tool_calls: list[dict[str, Any]],
|
|
conformance: str,
|
|
) -> None:
|
|
request_overrides = {"tools": [LOOKUP_TOOL], "horde": {"conformance": conformance}}
|
|
|
|
request_id, submit_response = _serve_reply(client, request_headers, chat_worker, request_overrides, content=None, tool_calls=tool_calls)
|
|
|
|
assert submit_response.status_code == 400
|
|
assert submit_response.get_json()["rc"] == "ChatSubmitInvalid"
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_strict_single_call_request_shows_only_the_first_of_several_calls(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
monkeypatch,
|
|
) -> None:
|
|
from horde.apis.v2 import base
|
|
|
|
log_records: list[tuple[str, dict[str, Any]]] = []
|
|
monkeypatch.setattr(base.logfire, "info", lambda event_name, **attributes: log_records.append((event_name, attributes)))
|
|
first_call, second_call = _tool_call(call_id="call_one"), _tool_call("send_email", "call_two")
|
|
request_overrides = {"tools": [EMAIL_TOOL, LOOKUP_TOOL], "parallel_tool_calls": False}
|
|
|
|
request_id, submit_response = _serve_reply(
|
|
client, request_headers, chat_worker, request_overrides, content=None, tool_calls=[first_call, second_call]
|
|
)
|
|
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
[generation] = _chat_status(client, request_headers, request_id)["generations"]
|
|
assert generation["message"] == {"role": "assistant", "content": None, "tool_calls": [first_call]}
|
|
assert generation["finish_reason"] == "tool_calls"
|
|
[submit_record] = [attributes for event_name, attributes in log_records if event_name == "horde.job_submit.done"]
|
|
assert submit_record["dropped_tool_calls"] == 1
|
|
|
|
|
|
def test_a_strict_request_without_parallel_tool_calls_goes_to_a_worker_without_the_single_call_guarantee(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""The Horde reduces a strict reply's tool calls to the first itself, so routing does not need
|
|
``single_tool_call_enforced``."""
|
|
request_id = _submit_chat_request(client, request_headers, tools=[LOOKUP_TOOL], parallel_tool_calls=False)
|
|
|
|
pop_result = chat_worker.pop(chat_features=["tools"], chat_guarantees=["prompt_overflow_refused"])
|
|
|
|
assert pop_result["id"] is not None, pop_result
|
|
assert pop_result["payload"]["parallel_tool_calls"] is False
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
@pytest.mark.parametrize("conformance", ["strict", "best_effort"])
|
|
def test_a_named_tool_choice_reply_finishes_with_stop_as_under_openai(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
conformance: str,
|
|
) -> None:
|
|
request_overrides = {"tools": [EMAIL_TOOL, LOOKUP_TOOL], "tool_choice": NAMED_LOOKUP_CHOICE, "horde": {"conformance": conformance}}
|
|
|
|
request_id, submit_response = _serve_reply(
|
|
client, request_headers, chat_worker, request_overrides, content=None, tool_calls=[_tool_call()]
|
|
)
|
|
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
[generation] = _chat_status(client, request_headers, request_id)["generations"]
|
|
assert generation["finish_reason"] == "stop"
|
|
assert generation["message"]["tool_calls"] == [_tool_call()]
|
|
|
|
|
|
def test_a_strict_schema_reply_matching_the_schema_completes(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_overrides = {"response_format": _colour_format(strict=True)}
|
|
|
|
request_id, submit_response = _serve_reply(client, request_headers, chat_worker, request_overrides, content='{"name": "blue"}')
|
|
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
assert _chat_status(client, request_headers, request_id)["generations"][0]["text"] == '{"name": "blue"}'
|
|
|
|
|
|
def test_a_non_strict_schema_accepts_any_json_object(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_overrides = {"response_format": _colour_format(strict=False)}
|
|
|
|
request_id, submit_response = _serve_reply(client, request_headers, chat_worker, request_overrides, content='{"name": 7}')
|
|
|
|
assert submit_response.status_code == 200, submit_response.get_data(as_text=True)
|
|
assert _chat_status(client, request_headers, request_id)["done"] is True
|
|
|
|
|
|
def _refuse_then_fault(
|
|
chat_worker: FakeChatWorker,
|
|
*,
|
|
content: str | None,
|
|
tool_calls: list[dict[str, Any]] | None = None,
|
|
fault_reason: str | None = None,
|
|
) -> Any:
|
|
"""Pop one job, submit a reply to it, then submit the fault a worker sends after a refusal; return the reply's response."""
|
|
pop_result = chat_worker.pop(chat_features=VERIFIED_FEATURES)
|
|
assert pop_result["id"] is not None, pop_result
|
|
reply_response = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=pop_result["id"],
|
|
content=content,
|
|
tool_calls=tool_calls,
|
|
finish_reason="tool_calls" if tool_calls else "stop",
|
|
model_id=CHAT_MODEL,
|
|
),
|
|
)
|
|
fault_response = chat_worker.submit(
|
|
build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL, fault_reason=fault_reason),
|
|
)
|
|
assert fault_response.status_code == 200, fault_response.get_data(as_text=True)
|
|
return reply_response
|
|
|
|
|
|
def _worker_counters(app, worker_name: str) -> tuple[int, int, int]:
|
|
"""Return the worker's stored ``aborted_jobs``, ``uncompleted_jobs`` and ``nonconformant_replies``."""
|
|
from horde.classes.kobold.worker import TextWorker
|
|
from horde.flask import db
|
|
|
|
with app.app_context():
|
|
aborted_jobs, uncompleted_jobs, nonconformant_replies = (
|
|
db.session.query(TextWorker.aborted_jobs, TextWorker.uncompleted_jobs, TextWorker.nonconformant_replies)
|
|
.filter_by(name=worker_name)
|
|
.one()
|
|
)
|
|
return aborted_jobs, uncompleted_jobs, nonconformant_replies
|
|
|
|
|
|
def test_a_request_faulted_after_refused_replies_reports_the_last_refusal(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
chat_events: Any,
|
|
) -> None:
|
|
from horde.classes.base.waiting_prompt import FAULTED_GENERATIONS_LIMIT
|
|
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
replies = ["Blue."] * FAULTED_GENERATIONS_LIMIT + ["[1]"]
|
|
refusals = [_refuse_then_fault(chat_worker, content=reply) for reply in replies]
|
|
|
|
assert [refusal.status_code for refusal in refusals] == [400] * len(replies)
|
|
last_refusal = refusals[-1].get_json()
|
|
assert refusals[0].get_json()["message"] != last_refusal["message"]
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["faulted"] is True
|
|
assert status["fault_reason"] == last_refusal["message"]
|
|
assert status["fault_rc"] == "ChatSubmitNonconformant"
|
|
faulted_event = {
|
|
"id": request_id,
|
|
"event": "faulted",
|
|
"generations": 0,
|
|
"fault_reason": last_refusal["message"],
|
|
"fault_rc": "ChatSubmitNonconformant",
|
|
}
|
|
assert (chat_signal_channel(request_id), faulted_event) in _received_events(chat_events)
|
|
|
|
|
|
def test_an_unfaulted_request_reports_no_fault_reason_after_a_refused_reply(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
|
|
assert _refuse_then_fault(chat_worker, content="Blue.").status_code == 400
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["faulted"] is False
|
|
assert status["fault_reason"] is None
|
|
assert status["fault_rc"] is None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("request_overrides", "content", "tool_calls", "refusal_rc", "dropped_jobs", "nonconformant_replies"),
|
|
[
|
|
pytest.param({"response_format": {"type": "json_object"}}, "Blue.", None, "ChatSubmitNonconformant", 0, 1, id="nonconformant"),
|
|
pytest.param({"tools": [LOOKUP_TOOL]}, None, [_tool_call("send_email")], "ChatSubmitInvalid", 1, 0, id="invalid"),
|
|
],
|
|
)
|
|
def test_a_fault_after_a_refused_reply_counts_as_a_dropped_job_unless_the_reply_was_nonconformant(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
request_overrides: dict[str, Any],
|
|
content: str | None,
|
|
tool_calls: list[dict[str, Any]] | None,
|
|
refusal_rc: str,
|
|
dropped_jobs: int,
|
|
nonconformant_replies: int,
|
|
) -> None:
|
|
worker = FakeChatWorker(client=client, headers=request_headers, name=f"CICD Chat Refusals {uuid.uuid4().hex[:8]}", models=[CHAT_MODEL])
|
|
request_id = _submit_chat_request(client, request_headers, **request_overrides)
|
|
|
|
refusal = _refuse_then_fault(worker, content=content, tool_calls=tool_calls)
|
|
|
|
assert refusal.get_json()["rc"] == refusal_rc
|
|
assert _worker_counters(app, worker.name) == (dropped_jobs, dropped_jobs, nonconformant_replies)
|
|
details = client.get(f"/api/v2/workers/name/{worker.name}", headers=request_headers)
|
|
assert details.status_code == 200, details.get_data(as_text=True)
|
|
assert details.get_json()["nonconformant_replies"] == nonconformant_replies
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_nonconformant_reply_for_a_stale_aborted_generation_is_refused_as_aborted_without_a_stored_refusal(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
) -> None:
|
|
"""A reply for a generation the stale-generation path faulted returns ``AbortedGen`` before its conformance check:
|
|
the request does not store a refusal, the abort counts as a dropped job and the worker's nonconformant replies do
|
|
not change."""
|
|
from horde.classes.kobold.processing_generation import TextProcessingGeneration
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
worker = FakeChatWorker(client=client, headers=request_headers, name=f"CICD Chat Stale {uuid.uuid4().hex[:8]}", models=[CHAT_MODEL])
|
|
_submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
pop_result = worker.pop(chat_features=VERIFIED_FEATURES)
|
|
assert pop_result["id"] is not None, pop_result
|
|
with app.app_context():
|
|
generation = db.session.get(TextProcessingGeneration, uuid.UUID(pop_result["id"]))
|
|
assert generation is not None
|
|
request_id = str(generation.wp_id)
|
|
generation.start_time = datetime.utcnow() - timedelta(seconds=generation.job_ttl + 1)
|
|
db.session.commit()
|
|
assert generation.is_stale()
|
|
generation.fault_and_restart(stale=True)
|
|
|
|
response = worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=pop_result["id"],
|
|
content="Blue.",
|
|
finish_reason="stop",
|
|
model_id=CHAT_MODEL,
|
|
),
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "AbortedGen"
|
|
assert _worker_counters(app, worker.name) == (1, 1, 0)
|
|
with app.app_context():
|
|
stored_refusal = (
|
|
db.session.query(TextWaitingPrompt.chat_fault_reason, TextWaitingPrompt.chat_fault_rc)
|
|
.filter(TextWaitingPrompt.id == uuid.UUID(request_id))
|
|
.one()
|
|
)
|
|
stored_refusal_rc = (
|
|
db.session.query(TextProcessingGeneration.chat_refusal_rc)
|
|
.filter(TextProcessingGeneration.id == uuid.UUID(pop_result["id"]))
|
|
.scalar()
|
|
)
|
|
assert tuple(stored_refusal) == (None, None)
|
|
assert stored_refusal_rc is None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def _fault_past_the_limit(chat_worker: FakeChatWorker, fault_reason: str) -> None:
|
|
"""Pop and fault the queued request's job with ``fault_reason`` until the request faults."""
|
|
from horde.classes.base.waiting_prompt import FAULTED_GENERATIONS_LIMIT
|
|
|
|
for _ in range(FAULTED_GENERATIONS_LIMIT + 1):
|
|
pop_result = chat_worker.pop()
|
|
assert pop_result["id"] is not None, pop_result
|
|
response = chat_worker.submit(
|
|
build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL, fault_reason=fault_reason),
|
|
)
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
|
|
|
|
def test_a_request_faulted_with_a_worker_fault_reason_reports_the_horde_message_for_it(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_events: Any,
|
|
) -> None:
|
|
"""The status and the faulted signal carry the Horde's sentence for the reason, and each fault is a dropped job."""
|
|
from horde_sdk.worker.chat_declaration import ChatFaultReason
|
|
|
|
from horde.classes.base.waiting_prompt import FAULTED_GENERATIONS_LIMIT
|
|
from horde.classes.kobold.chat_request import CHAT_WORKER_FAULT_MESSAGES
|
|
|
|
worker = FakeChatWorker(client=client, headers=request_headers, name=f"CICD Chat Faults {uuid.uuid4().hex[:8]}", models=[CHAT_MODEL])
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
|
|
_fault_past_the_limit(worker, "prompt_overflow")
|
|
|
|
expected_message = CHAT_WORKER_FAULT_MESSAGES[ChatFaultReason.PROMPT_OVERFLOW]
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["faulted"] is True
|
|
assert status["fault_reason"] == expected_message
|
|
assert status["fault_rc"] == "ChatWorkerFault"
|
|
faulted_event = {
|
|
"id": request_id,
|
|
"event": "faulted",
|
|
"generations": 0,
|
|
"fault_reason": expected_message,
|
|
"fault_rc": "ChatWorkerFault",
|
|
}
|
|
assert (chat_signal_channel(request_id), faulted_event) in _received_events(chat_events)
|
|
dropped_jobs = FAULTED_GENERATIONS_LIMIT + 1
|
|
assert _worker_counters(app, worker.name) == (dropped_jobs, dropped_jobs, 0)
|
|
|
|
|
|
def test_an_unknown_worker_fault_reason_is_ignored(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
|
|
_fault_past_the_limit(chat_worker, "reactor_meltdown")
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["faulted"] is True
|
|
assert status["fault_reason"] is None
|
|
assert status["fault_rc"] is None
|
|
|
|
|
|
def test_a_reply_refused_fault_keeps_the_message_of_the_refusal_it_follows(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
from horde.classes.base.waiting_prompt import FAULTED_GENERATIONS_LIMIT
|
|
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
refusals = [
|
|
_refuse_then_fault(chat_worker, content="Blue.", fault_reason="reply_refused") for _ in range(FAULTED_GENERATIONS_LIMIT + 1)
|
|
]
|
|
|
|
assert [refusal.status_code for refusal in refusals] == [400] * len(refusals)
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["faulted"] is True
|
|
assert status["fault_reason"] == refusals[-1].get_json()["message"]
|
|
assert status["fault_rc"] == "ChatSubmitNonconformant"
|
|
|
|
|
|
class _CounterRecorder:
|
|
"""Stands in for a metric counter and keeps every increment with its attributes."""
|
|
|
|
def __init__(self) -> None:
|
|
self.increments: list[tuple[int, dict[str, str]]] = []
|
|
|
|
def add(self, amount: int, attributes: dict[str, str]) -> None:
|
|
self.increments.append((amount, attributes))
|
|
|
|
|
|
@pytest.fixture
|
|
def worker_fault_counter(monkeypatch: pytest.MonkeyPatch) -> _CounterRecorder:
|
|
"""Replace the chat worker fault counter with a recorder."""
|
|
from horde import metrics
|
|
|
|
recorder = _CounterRecorder()
|
|
monkeypatch.setattr(metrics, "chat_worker_faults", recorder)
|
|
return recorder
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("fault_reason", "expected_label"),
|
|
[
|
|
("prompt_overflow", "prompt_overflow"),
|
|
("reactor_meltdown", "unknown"),
|
|
(None, "none"),
|
|
],
|
|
)
|
|
def test_a_faulted_chat_submit_is_counted_once_by_its_reason_label(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
worker_fault_counter: _CounterRecorder,
|
|
fault_reason: str | None,
|
|
expected_label: str,
|
|
) -> None:
|
|
"""A known reason is counted as itself, any other text as ``unknown`` and a missing reason as ``none``."""
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
response = chat_worker.submit(
|
|
build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL, fault_reason=fault_reason),
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
assert worker_fault_counter.increments == [
|
|
(
|
|
1,
|
|
{
|
|
"horde.reason": expected_label,
|
|
"horde.worker_trusted": "true",
|
|
},
|
|
),
|
|
]
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_refused_reply_and_the_fault_after_it_are_counted_once_by_the_workers_reason(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
worker_fault_counter: _CounterRecorder,
|
|
) -> None:
|
|
"""The refusal the Horde stores is not counted; the worker's fault after it is counted with the worker's reason."""
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
|
|
refusal = _refuse_then_fault(chat_worker, content="Blue.", fault_reason="reply_refused")
|
|
|
|
assert refusal.status_code == 400, refusal.get_data(as_text=True)
|
|
assert worker_fault_counter.increments == [
|
|
(
|
|
1,
|
|
{
|
|
"horde.reason": "reply_refused",
|
|
"horde.worker_trusted": "true",
|
|
},
|
|
),
|
|
]
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def _status_of_loaded_request(app, loaded_request: Any) -> dict[str, Any]:
|
|
"""Return the full status the status route would build from ``loaded_request``, an instance loaded earlier."""
|
|
with app.app_context():
|
|
return loaded_request.get_status(
|
|
request_avg=0,
|
|
active_worker_count=(0, 0),
|
|
has_valid_workers=True,
|
|
wp_queue_stats=(0, 0, 0),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("in_flight_result", [None, "success", "fault"])
|
|
def test_cancelled_chat_status_settles_only_after_in_flight_work(
|
|
client,
|
|
request_headers,
|
|
chat_worker,
|
|
in_flight_result,
|
|
):
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
popped = chat_worker.pop() if in_flight_result else None
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["waiting"] == 0
|
|
assert status["done"] is (in_flight_result is None)
|
|
if popped:
|
|
assert status["processing"] == 1
|
|
if in_flight_result == "success":
|
|
response = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=popped["id"],
|
|
content="Finished after cancellation.",
|
|
model_id=CHAT_MODEL,
|
|
)
|
|
)
|
|
else:
|
|
response = chat_worker.submit(build_faulted_chat_submit_body(generation_id=popped["id"], model_id=CHAT_MODEL))
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert status["done"] is True
|
|
assert status["processing"] == status["waiting"] == 0
|
|
assert len(status["generations"]) == int(in_flight_result == "success")
|
|
|
|
|
|
def test_a_status_built_from_a_request_loaded_before_a_refused_reply_is_not_done_without_a_generation(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""The status route loads the request, then spends time on worker availability and the queue before it reads the
|
|
generations. A worker's fault committed in between must not make the status done with no generation and no fault:
|
|
the job is restarted in the fault's commit and the status reads the request's state in one statement."""
|
|
from sqlalchemy.orm import Session
|
|
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
pop_result = chat_worker.pop(chat_features=VERIFIED_FEATURES)
|
|
assert pop_result["id"] is not None, pop_result
|
|
with app.app_context():
|
|
status_reader_session = Session(db.engine)
|
|
try:
|
|
loaded_request = status_reader_session.get(TextWaitingPrompt, uuid.UUID(request_id))
|
|
assert loaded_request is not None
|
|
assert loaded_request.n == 0
|
|
|
|
refusal = chat_worker.submit(
|
|
build_chat_submit_body(generation_id=pop_result["id"], content="Blue.", finish_reason="stop", model_id=CHAT_MODEL),
|
|
)
|
|
assert refusal.get_json()["rc"] == "ChatSubmitNonconformant"
|
|
fault = chat_worker.submit(build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL))
|
|
assert fault.status_code == 200, fault.get_data(as_text=True)
|
|
|
|
status = _status_of_loaded_request(app, loaded_request)
|
|
finally:
|
|
status_reader_session.close()
|
|
assert (status["done"], status["faulted"], status["restarted"], status["waiting"]) == (False, False, 1, 1)
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_restarted_request_is_not_completed_while_a_worker_takes_its_job_again(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""After a refused reply's fault, the pop that takes the restarted job commits the taken job with its generation, so
|
|
another session never sees the request with no job waiting, none processing and only the faulted one finished."""
|
|
from sqlalchemy.orm import Session
|
|
|
|
from horde.classes.base.waiting_prompt import RequestCompletionSnapshot, WaitingPrompt
|
|
from horde.classes.kobold.processing_generation import TextProcessingGeneration
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
assert _refuse_then_fault(chat_worker, content="Blue.").get_json()["rc"] == "ChatSubmitNonconformant"
|
|
completed_while_taken: list[bool] = []
|
|
select_model = WaitingPrompt._select_model
|
|
|
|
def read_completion_then_select_model(waiting_prompt: Any, worker: Any, declared_models: list[str] | None = None) -> str:
|
|
# Runs inside the pop, between taking the job and creating its generation.
|
|
with Session(db.engine) as other_session:
|
|
stored_request = other_session.get(TextWaitingPrompt, uuid.UUID(request_id))
|
|
stored_generations = other_session.query(TextProcessingGeneration).filter_by(wp_id=stored_request.id, fake=False).all()
|
|
snapshot = RequestCompletionSnapshot(
|
|
n=stored_request.n,
|
|
faulted=stored_request.faulted,
|
|
completed_jobs=sum(1 for generation in stored_generations if not generation.faulted and generation.generation is not None),
|
|
processing_jobs=sum(1 for generation in stored_generations if not generation.faulted and generation.generation is None),
|
|
fault_reason=None,
|
|
fault_rc=None,
|
|
)
|
|
completed_while_taken.append(snapshot.is_completed(stored_request.jobs))
|
|
return select_model(waiting_prompt, worker, declared_models)
|
|
|
|
monkeypatch.setattr(WaitingPrompt, "_select_model", read_completion_then_select_model)
|
|
reissued = chat_worker.pop(chat_features=VERIFIED_FEATURES)
|
|
|
|
assert reissued["id"] is not None, reissued
|
|
assert completed_while_taken == [False]
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_repeated_faulted_submit_restarts_the_job_once(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""Only the submit that moves the generation from pending to faulted restarts its job: a repeated faulted submit,
|
|
and a fault from an instance loaded before the first fault committed, leave the restarted count alone."""
|
|
from sqlalchemy.orm import Session
|
|
|
|
from horde.classes.kobold.processing_generation import TextProcessingGeneration
|
|
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
|
|
from horde.flask import db
|
|
|
|
request_id = _submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
assert pop_result["id"] is not None, pop_result
|
|
with app.app_context():
|
|
stale_session = Session(db.engine)
|
|
try:
|
|
stale_generation = stale_session.get(TextProcessingGeneration, uuid.UUID(pop_result["id"]))
|
|
assert stale_generation is not None
|
|
assert stale_generation.faulted is False
|
|
|
|
for _ in range(2):
|
|
response = chat_worker.submit(build_faulted_chat_submit_body(generation_id=pop_result["id"], model_id=CHAT_MODEL))
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
with app.app_context():
|
|
stale_generation.fault_and_restart()
|
|
stored_n = db.session.query(TextWaitingPrompt.n).filter(TextWaitingPrompt.id == uuid.UUID(request_id)).scalar()
|
|
finally:
|
|
stale_session.close()
|
|
|
|
assert stored_n == 1
|
|
status = _chat_status(client, request_headers, request_id)
|
|
assert (status["restarted"], status["waiting"], status["done"], status["faulted"]) == (1, 1, False, False)
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_a_request_on_its_third_attempt_is_not_done_while_the_job_is_processing(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""Faulted generations do not count toward completion: two faulted attempts and a third one processing leave the
|
|
request open, not done without a generation for the whole third attempt."""
|
|
request_id = _submit_chat_request(client, request_headers, response_format={"type": "json_object"})
|
|
for _ in range(2):
|
|
assert _refuse_then_fault(chat_worker, content="Blue.").get_json()["rc"] == "ChatSubmitNonconformant"
|
|
third_attempt = chat_worker.pop(chat_features=VERIFIED_FEATURES)
|
|
assert third_attempt["id"] is not None, third_attempt
|
|
|
|
status = _chat_status(client, request_headers, request_id)
|
|
|
|
assert (status["done"], status["faulted"], status["processing"], status["restarted"]) == (False, False, 1, 2)
|
|
_cancel(client, request_headers, "chat", request_id)
|
|
|
|
|
|
def test_reasoning_is_stored_and_shown_beside_the_reply_and_neither_it_nor_its_count_is_charged(
|
|
app,
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
"""The Horde charges and rewards on the reply it measures, so a reply with reasoning earns what the same reply
|
|
without it earns."""
|
|
from horde.classes.kobold.processing_generation import TextProcessingGeneration
|
|
from horde.flask import db
|
|
|
|
reasoning_request_id = _submit_chat_request(client, request_headers, max_tokens=400)
|
|
reasoning_pop = chat_worker.pop()
|
|
reasoning_submit = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=reasoning_pop["id"],
|
|
content="Blue.",
|
|
model_id=CHAT_MODEL,
|
|
completion_tokens=300,
|
|
reasoning_content="The user asked for a colour. " * 40,
|
|
reasoning_tokens=297,
|
|
),
|
|
)
|
|
assert reasoning_submit.status_code == 200, reasoning_submit.get_data(as_text=True)
|
|
|
|
_submit_chat_request(client, request_headers, max_tokens=400)
|
|
plain_pop = chat_worker.pop()
|
|
plain_submit = chat_worker.submit(build_chat_submit_body(generation_id=plain_pop["id"], content="Blue.", model_id=CHAT_MODEL))
|
|
assert plain_submit.status_code == 200, plain_submit.get_data(as_text=True)
|
|
|
|
[generation] = _chat_status(client, request_headers, reasoning_request_id)["generations"]
|
|
assert generation["text"] == "Blue."
|
|
assert generation["message"] == {
|
|
"role": "assistant",
|
|
"content": "Blue.",
|
|
"reasoning_content": "The user asked for a colour. " * 40,
|
|
}
|
|
assert generation["completion_tokens"] == 300
|
|
assert generation["reasoning_tokens"] == 297
|
|
with app.app_context():
|
|
stored = db.session.get(TextProcessingGeneration, uuid.UUID(reasoning_pop["id"]))
|
|
assert stored.generation == "Blue."
|
|
assert stored.reasoning_tokens == 297
|
|
assert reasoning_submit.get_json()["reward"] == plain_submit.get_json()["reward"]
|
|
|
|
|
|
def test_a_submit_with_reasoning_over_the_text_cap_is_refused_as_invalid(
|
|
client,
|
|
request_headers: dict[str, str],
|
|
chat_worker: FakeChatWorker,
|
|
) -> None:
|
|
from horde_sdk.generation_parameters.text.chat_limits import MAX_CHAT_TEXT_BYTES
|
|
|
|
_submit_chat_request(client, request_headers)
|
|
pop_result = chat_worker.pop()
|
|
|
|
response = chat_worker.submit(
|
|
build_chat_submit_body(
|
|
generation_id=pop_result["id"],
|
|
content="Blue.",
|
|
model_id=CHAT_MODEL,
|
|
reasoning_content="x" * (MAX_CHAT_TEXT_BYTES + 1),
|
|
),
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_data(as_text=True)
|
|
assert response.get_json()["rc"] == "ChatSubmitInvalid"
|