Reported from a live instance, on Bonsai: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low. Effort goes out two ways because no single field works, and the second -- chat_template_kwargs -- is not a parameter the server interprets. It is rendered into the model's own chat template, which does not ignore a value it does not know: it calls raise_exception, and the request dies before a token. So a perfectly ordinary option, drawn by this application in its own menu, took the whole reply with it. The vocabulary is per model and nobody agrees. gpt-oss takes low/medium/high. Bonsai takes low/medium/xhigh and refuses high. OpenAI has added minimal, xhigh and max at different points, and which of them a given model accepts varies again. One global tuple was going to be wrong for somebody whatever it held. A model carries its own list now, and the picker, the slash command and the request builder all read it. A column rather than a key in capabilities_json, for the reason context_length is one: that dict is rebuilt wholesale from the submitted checkboxes on every save. And it corrects itself. A refusal retries the reply once without the effort rather than losing it -- safe only because the template renders before any token, so nothing has been emitted, and there is a guard that keeps it that way -- then narrows the model's list. Bonsai's error states what it does take, so that is what gets stored. Note the parser bug, because it is a good one: "high" is a substring of "xhigh", so reading the advertised list by substring learned `high` from a sentence explaining that `high` is the problem. Whole words now, with a test named after it. /effort reads its levels off the picker instead of a second copy of the list kept in the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""Reasoning effort: what goes out, and what does not.
|
|
|
|
The second half matters as much as the first. There is no field that works
|
|
everywhere -- OpenAI and vLLM read `reasoning_effort`, llama.cpp drops it
|
|
silently and reads only `chat_template_kwargs` -- so both are sent. That is only
|
|
safe because neither is sent at all until somebody chooses an effort, which is
|
|
what keeps an endpoint strict about unknown parameters working exactly as it
|
|
did.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import Chat, Connection, Model, User
|
|
from lembas.services import chat as chat_service
|
|
|
|
from .conftest import control_named
|
|
|
|
|
|
def _model(db, **capabilities) -> Model:
|
|
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
|
db.add(connection)
|
|
db.commit()
|
|
model = Model(
|
|
connection_id=connection.id,
|
|
model_id="m",
|
|
capabilities_json={"reasoning": True, **capabilities},
|
|
)
|
|
db.add(model)
|
|
db.commit()
|
|
return model
|
|
|
|
|
|
def _chat(db, effort: str | None = None) -> Chat:
|
|
model = _model(db)
|
|
user = db.scalars(select(User)).first()
|
|
chat = Chat(
|
|
user_id=user.id,
|
|
model_id=model.model_id,
|
|
connection_id=model.connection_id,
|
|
params_json={"reasoning_effort": effort} if effort is not None else {},
|
|
)
|
|
db.add(chat)
|
|
db.commit()
|
|
return chat
|
|
|
|
|
|
# --- What reaches the endpoint -----------------------------------------------
|
|
def test_an_effort_goes_out_in_both_forms(client: TestClient, db, registered):
|
|
"""One value, two fields. Neither endpoint family reads the other's."""
|
|
chat = _chat(db, "high")
|
|
|
|
body = chat_service.build_request(db, chat)
|
|
|
|
assert body["reasoning_effort"] == "high"
|
|
assert body["chat_template_kwargs"] == {"reasoning_effort": "high"}
|
|
|
|
|
|
def test_no_effort_means_neither_field(client: TestClient, db, registered):
|
|
"""The whole safety of sending both. A chat nobody has set an effort on is
|
|
byte-for-byte the request it was before this existed, so a provider that
|
|
refuses unknown parameters is untouched until somebody opts in."""
|
|
chat = _chat(db)
|
|
|
|
body = chat_service.build_request(db, chat)
|
|
|
|
assert "reasoning_effort" not in body
|
|
assert "chat_template_kwargs" not in body
|
|
|
|
|
|
def test_a_cleared_effort_means_neither_field(client: TestClient, db, registered):
|
|
"""Cleared is stored as None, like every other parameter here."""
|
|
chat = _chat(db, None)
|
|
|
|
body = chat_service.build_request(db, chat)
|
|
|
|
assert "reasoning_effort" not in body
|
|
|
|
|
|
@pytest.mark.parametrize("junk", ["sudo", "HIGH ", "maximum", "1"])
|
|
def test_a_value_that_is_not_an_effort_is_not_sent(client: TestClient, db, registered, junk):
|
|
"""Never trusted from the row: it could predate a change to the list."""
|
|
chat = _chat(db, junk)
|
|
|
|
assert "reasoning_effort" not in chat_service.build_request(db, chat)
|
|
|
|
|
|
def test_existing_chat_template_kwargs_are_kept(client: TestClient, db, registered):
|
|
"""Merged rather than replaced, so a future caller setting something else
|
|
there does not lose it."""
|
|
body: dict = {"chat_template_kwargs": {"enable_thinking": True}}
|
|
|
|
chat_service.apply_effort(body, "low")
|
|
|
|
assert body["chat_template_kwargs"] == {"enable_thinking": True, "reasoning_effort": "low"}
|
|
|
|
|
|
# --- Setting it ---------------------------------------------------------------
|
|
def test_patching_the_effort_stores_it(client: TestClient, db, registered):
|
|
chat = _chat(db)
|
|
|
|
assert client.patch(
|
|
f"/api/chats/{chat.id}", data={"reasoning_effort": "medium"}
|
|
).status_code == 204
|
|
|
|
db.refresh(chat)
|
|
assert chat.params_json["reasoning_effort"] == "medium"
|
|
|
|
|
|
def test_an_empty_effort_clears_it(client: TestClient, db, registered):
|
|
chat = _chat(db, "high")
|
|
|
|
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": ""})
|
|
|
|
db.refresh(chat)
|
|
assert chat.params_json["reasoning_effort"] is None
|
|
|
|
|
|
def test_an_unknown_effort_leaves_the_old_one(client: TestClient, db, registered):
|
|
"""Ignored, not refused: a typo should not cost the setting you had."""
|
|
chat = _chat(db, "low")
|
|
|
|
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": "extreme"})
|
|
|
|
db.refresh(chat)
|
|
assert chat.params_json["reasoning_effort"] == "low"
|
|
|
|
|
|
def test_the_control_only_appears_on_a_reasoning_model(client: TestClient, db, registered):
|
|
"""The flag has existed with no reader since the beginning; this is its
|
|
first job. Offering the control everywhere would offer a setting that does
|
|
nothing almost everywhere."""
|
|
chat = _chat(db)
|
|
assert "data-effort" in client.get(f"/chat/{chat.id}").text
|
|
|
|
model = db.scalars(select(Model)).one()
|
|
model.capabilities_json = {"reasoning": False}
|
|
db.commit()
|
|
|
|
assert "data-effort" not in client.get(f"/chat/{chat.id}").text
|
|
|
|
|
|
# --- The per-model default ----------------------------------------------------
|
|
def test_a_new_chat_starts_from_the_models_defaults(client: TestClient, db, registered):
|
|
"""`Model.params_json` has claimed to do this since it was added and did it
|
|
nowhere. It is empty on every existing row, so honouring it changes nothing
|
|
until an administrator sets something."""
|
|
model = _model(db)
|
|
model.params_json = {"reasoning_effort": "high"}
|
|
db.commit()
|
|
|
|
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
|
|
|
chat = db.scalars(select(Chat)).one()
|
|
assert chat.params_json["reasoning_effort"] == "high"
|
|
|
|
|
|
def test_a_model_with_no_defaults_starts_a_plain_chat(client: TestClient, db, registered):
|
|
_model(db)
|
|
|
|
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
|
|
|
assert db.scalars(select(Chat)).one().params_json == {}
|
|
|
|
|
|
# --- Choosable before the first prompt ---------------------------------------
|
|
def test_the_effort_select_carries_its_own_verb(client: TestClient, db, registered):
|
|
"""The same invariant the mode select needs, for the same reason.
|
|
|
|
Both were built on `form="…"` pointing at an empty sibling form holding the
|
|
`hx-patch`, and both therefore wrote nothing at all: `form=` scopes the
|
|
values a request carries, it does not route the event that starts one.
|
|
"""
|
|
chat = _chat(db)
|
|
|
|
select = control_named(client.get(f"/chat/{chat.id}").text, "reasoning_effort")
|
|
assert select["hx-patch"] == f"/api/chats/{chat.id}"
|
|
assert select["form"] == "chat-params-form"
|
|
|
|
|
|
def test_the_effort_is_offered_before_there_is_a_chat(client: TestClient, db, registered):
|
|
"""Otherwise it is a setting you can only reach once it is too late to use.
|
|
|
|
On the new-chat screen there is nothing to PATCH, so it is an ordinary field
|
|
of the composer's form and carries no verb -- `_new_chat` reads it.
|
|
"""
|
|
_model(db)
|
|
|
|
select = control_named(client.get("/chat").text, "reasoning_effort")
|
|
assert "hx-patch" not in select
|
|
assert "form" not in select
|
|
|
|
|
|
def test_starting_a_chat_with_an_effort_stores_it(client: TestClient, db, registered):
|
|
_model(db)
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={"content": "hello", "model_id": "m", "reasoning_effort": "low"},
|
|
)
|
|
|
|
assert db.scalars(select(Chat)).one().params_json["reasoning_effort"] == "low"
|
|
|
|
|
|
def test_an_explicit_effort_beats_the_models_default(client: TestClient, db, registered):
|
|
"""An inherited value is a starting point, not a ceiling."""
|
|
model = _model(db)
|
|
model.params_json = {"reasoning_effort": "high"}
|
|
db.commit()
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={"content": "hello", "model_id": "m", "reasoning_effort": "low"},
|
|
)
|
|
|
|
assert db.scalars(select(Chat)).one().params_json["reasoning_effort"] == "low"
|
|
|
|
|
|
def test_a_nonsense_effort_at_the_start_falls_back(client: TestClient, db, registered):
|
|
model = _model(db)
|
|
model.params_json = {"reasoning_effort": "high"}
|
|
db.commit()
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={"content": "hello", "model_id": "m", "reasoning_effort": "extreme"},
|
|
)
|
|
|
|
assert db.scalars(select(Chat)).one().params_json["reasoning_effort"] == "high"
|
|
|
|
|
|
# --- What the picker says is what is sent ---------------------------------------
|
|
def test_the_resolver_is_what_the_request_carries(client: TestClient, db, registered):
|
|
"""One resolver, so the control and the request cannot disagree. That
|
|
disagreement is the whole reason the picker said "default": it named no
|
|
level, and was true of nothing in particular."""
|
|
chat = _chat(db, "high")
|
|
|
|
assert chat_service.resolved_effort(chat) == "high"
|
|
assert chat_service.build_request(db, chat)["reasoning_effort"] == "high"
|
|
|
|
|
|
def test_a_cleared_effort_is_not_resurrected_by_the_models_default(
|
|
client: TestClient, db, registered
|
|
):
|
|
"""What the no-fallback decision buys. If `build_request` fell back to the
|
|
model, `update_chat` storing None for a cleared effort would be undone
|
|
underneath it and the off option would silently do nothing."""
|
|
model = _model(db)
|
|
model.params_json = {"reasoning_effort": "high"}
|
|
user = db.scalars(select(User)).first()
|
|
chat = Chat(
|
|
user_id=user.id,
|
|
model_id=model.model_id,
|
|
connection_id=model.connection_id,
|
|
params_json={"reasoning_effort": None},
|
|
)
|
|
db.add(chat)
|
|
db.commit()
|
|
|
|
assert chat_service.resolved_effort(chat) == ""
|
|
body = chat_service.build_request(db, chat)
|
|
assert "reasoning_effort" not in body
|
|
assert "chat_template_kwargs" not in body
|
|
|
|
|
|
def test_choosing_off_before_the_chat_exists_sends_nothing(
|
|
client: TestClient, db, registered
|
|
):
|
|
"""The one that would otherwise ship broken. `start_chat` declares
|
|
`Form("")`, so an absent field and an empty one are the same thing there --
|
|
with `value=""` on the off option the reader picks off, the value falls out
|
|
of EFFORTS, and the model's default seeded onto the row stays. They get
|
|
"high"."""
|
|
model = _model(db)
|
|
model.params_json = {"reasoning_effort": "high"}
|
|
db.commit()
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={"content": "hello", "model_id": "m", "reasoning_effort": "off"},
|
|
)
|
|
chat = db.scalars(select(Chat)).first()
|
|
|
|
assert not (chat.params_json or {}).get("reasoning_effort")
|
|
assert "reasoning_effort" not in chat_service.build_request(db, chat)
|
|
|
|
|
|
def test_patching_off_clears_it(client: TestClient, db, registered):
|
|
chat = _chat(db, "high")
|
|
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": "off"})
|
|
|
|
db.expire_all()
|
|
assert chat_service.resolved_effort(db.get(Chat, chat.id)) == ""
|
|
|
|
|
|
def test_switching_model_seeds_an_effort_that_was_never_chosen(
|
|
client: TestClient, db, registered
|
|
):
|
|
"""So "what the picker shows is what is sent" stays true after a switch."""
|
|
chat = _chat(db)
|
|
second = Model(
|
|
connection_id=chat.connection_id,
|
|
model_id="m2",
|
|
capabilities_json={"reasoning": True},
|
|
params_json={"reasoning_effort": "medium"},
|
|
)
|
|
db.add(second)
|
|
db.commit()
|
|
|
|
client.patch(f"/api/chats/{chat.id}", data={"model_id": "m2"})
|
|
|
|
db.expire_all()
|
|
assert chat_service.resolved_effort(db.get(Chat, chat.id)) == "medium"
|
|
|
|
|
|
def test_switching_model_does_not_overwrite_a_chosen_effort(
|
|
client: TestClient, db, registered
|
|
):
|
|
chat = _chat(db, "low")
|
|
second = Model(
|
|
connection_id=chat.connection_id,
|
|
model_id="m2",
|
|
capabilities_json={"reasoning": True},
|
|
params_json={"reasoning_effort": "high"},
|
|
)
|
|
db.add(second)
|
|
db.commit()
|
|
|
|
client.patch(f"/api/chats/{chat.id}", data={"model_id": "m2"})
|
|
|
|
db.expire_all()
|
|
assert chat_service.resolved_effort(db.get(Chat, chat.id)) == "low"
|
|
|
|
|
|
def test_switching_model_does_not_resurrect_a_cleared_effort(
|
|
client: TestClient, db, registered
|
|
):
|
|
"""`None` means somebody cleared it deliberately. Only an ABSENT key is
|
|
seeded, or "off" would silently undo itself on the next model change."""
|
|
chat = _chat(db, "high")
|
|
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": "off"})
|
|
second = Model(
|
|
connection_id=chat.connection_id,
|
|
model_id="m2",
|
|
capabilities_json={"reasoning": True},
|
|
params_json={"reasoning_effort": "high"},
|
|
)
|
|
db.add(second)
|
|
db.commit()
|
|
|
|
client.patch(f"/api/chats/{chat.id}", data={"model_id": "m2"})
|
|
|
|
db.expire_all()
|
|
assert chat_service.resolved_effort(db.get(Chat, chat.id)) == ""
|
|
|
|
|
|
def test_the_picker_never_says_default(client: TestClient, db, registered):
|
|
"""The one markup assertion. It named no level and was true of nothing."""
|
|
chat = _chat(db, "medium")
|
|
html = client.get(f"/chat/{chat.id}").text
|
|
|
|
assert "Effort: default" not in html
|
|
assert "Effort: off" in html
|
|
assert '<option value="medium" selected>' in html.replace("\n", "").replace(" ", "")
|
|
|
|
|
|
# --- A vocabulary that is not the same for every model -----------------------
|
|
#
|
|
# Reported from a real instance, on a model called Bonsai:
|
|
#
|
|
# Jinja Exception: Unexpected reasoning effort high. Supported types are
|
|
# xhigh (default), medium, and low.
|
|
#
|
|
# `chat_template_kwargs.reasoning_effort` is rendered into the model's own chat
|
|
# template, and a template that does not know the value calls `raise_exception`
|
|
# rather than ignoring it -- so the whole reply died, from an option this
|
|
# application had drawn in a menu.
|
|
BONSAI_ERROR = (
|
|
"Jinja Exception: Unexpected reasoning effort high. "
|
|
"Supported types are xhigh (default), medium, and low."
|
|
)
|
|
|
|
|
|
class _FakeModel:
|
|
def __init__(self, efforts=None):
|
|
self.reasoning_efforts = efforts or []
|
|
|
|
|
|
def test_a_model_that_has_said_nothing_gets_the_common_three():
|
|
from lembas.services import chat as chat_service
|
|
|
|
assert chat_service.efforts_for(_FakeModel()) == ("low", "medium", "high")
|
|
|
|
|
|
def test_a_model_can_take_xhigh_and_not_high():
|
|
from lembas.services import chat as chat_service
|
|
|
|
bonsai = _FakeModel(["xhigh", "medium", "low"])
|
|
assert chat_service.efforts_for(bonsai) == ("low", "medium", "xhigh")
|
|
assert "high" not in chat_service.efforts_for(bonsai)
|
|
|
|
|
|
def test_an_effort_the_model_refuses_is_never_sent():
|
|
"""The check that stops the crash happening at all."""
|
|
from lembas.services import chat as chat_service
|
|
|
|
supported = chat_service.efforts_for(_FakeModel(["xhigh", "medium", "low"]))
|
|
body: dict = {}
|
|
chat_service.apply_effort(body, "high", supported)
|
|
assert body == {}
|
|
|
|
chat_service.apply_effort(body, "xhigh", supported)
|
|
assert body["reasoning_effort"] == "xhigh"
|
|
assert body["chat_template_kwargs"]["reasoning_effort"] == "xhigh"
|
|
|
|
|
|
def test_a_value_this_application_never_heard_of_cannot_reach_a_request():
|
|
from lembas.services import chat as chat_service
|
|
|
|
assert chat_service.efforts_for(_FakeModel(["ludicrous"])) == ("low", "medium", "high")
|
|
|
|
|
|
def test_the_refusal_is_recognised_and_the_supported_list_read_out_of_it():
|
|
from lembas.services import generation
|
|
|
|
assert generation._effort_was_refused(BONSAI_ERROR)
|
|
assert generation._advertised_efforts(BONSAI_ERROR) == ["low", "medium", "xhigh"]
|
|
|
|
|
|
def test_the_rejected_value_is_not_collected_as_a_supported_one():
|
|
"""The message names the refused effort first and the supported ones after,
|
|
so anything reading the whole string would learn `high` from a sentence
|
|
saying `high` is the problem."""
|
|
from lembas.services import generation
|
|
|
|
assert "high" not in generation._advertised_efforts(BONSAI_ERROR)
|
|
|
|
|
|
def test_an_ordinary_failure_is_not_retried_as_an_effort_problem():
|
|
"""Retrying a genuine failure would hide it behind a second request."""
|
|
from lembas.services import generation
|
|
|
|
for message in (
|
|
"Connection refused.",
|
|
"The model is still loading.",
|
|
"context length exceeded",
|
|
):
|
|
assert not generation._effort_was_refused(message)
|
|
|
|
|
|
def test_a_model_with_no_advertisement_simply_loses_the_refused_value():
|
|
from lembas.services import generation
|
|
|
|
assert generation._advertised_efforts("Unexpected reasoning effort high.") == []
|