1 Commits
Author SHA1 Message Date
HomerandClaude Opus 5 b1dbca7db6 Reading the answer instead of asking somebody to know it
llama-server publishes the loaded model's Jinja chat template on /props, and
that template is the very thing that rejects a reasoning effort it does not
recognise -- so the accepted set is written down, authoritatively, in a place
this application can simply read. There is a button on the model's page that
does.

The parser handles both shapes a template uses: the values inline in the test
that rejects them (Bonsai), and a named list set elsewhere with nothing near
the mention spelling them out (gpt-oss). It is deliberately conservative,
because a wrong answer here silently removes a level somebody is entitled to:
only known efforts count, an unrelated list of quoted strings is ignored, and a
single match is read as a default -- `{%- set reasoning_effort = 'medium' %}`
-- rather than as a vocabulary of one.

An endpoint with no such route says so. OpenAI and vLLM do not publish a
template, and "this cannot tell us" must not be recorded as "this model accepts
nothing".

/props sits at the server root, beside the OpenAI-compatible surface rather
than inside it, so a base URL written as .../v1 needs the suffix stripped.
Getting that wrong is a silent 404 that looks like detection simply not
working, so there is a test on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 22:23:22 +00:00
7 changed files with 323 additions and 2 deletions
+11
View File
@@ -16,6 +16,17 @@ for 1.0.0 have something to be assembled from.
## Unreleased
## 1.3.0
- **A model's reasoning efforts can now be detected rather than known.** There
is a button on the model's page that asks the endpoint what its chat template
actually accepts, and ticks those. llama.cpp publishes the loaded model's
template, and that template is the very thing that rejects an effort it does
not recognise — so the answer is read from the place that is authoritative
instead of guessed at, or discovered by a failed reply.
- Endpoints that do not publish a template — OpenAI, vLLM — say so plainly
rather than being recorded as accepting nothing.
## 1.2.0
- Fixed: **choosing a reasoning effort could kill the reply outright**, with a
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.2.0"
__version__ = "1.3.0"
+62 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import contextlib
import logging
from urllib.parse import quote
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse
@@ -162,7 +163,13 @@ async def models_page(
@router.get("/admin/models/{model_id}/edit")
async def model_detail(
request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
request: Request,
db: Db,
user: AdminUser,
model_id: str,
saved: str = "",
detected: str = "",
message: str = "",
):
"""Everything about one model, on its own page."""
model = _model(db, model_id)
@@ -182,6 +189,11 @@ async def model_detail(
# current answer, which is the common three until somebody says.
"efforts": chat_service.EFFORTS,
"model_efforts": chat_service.efforts_for(model),
# What `detect-efforts` found, if it has just run. Escaped by the
# template like every other value; it is prose the endpoint or this
# application wrote, not markup.
"detected": detected if detected in ("success", "warning") else "",
"detected_message": message[:400],
# Rows predating the split have no tool_* keys at all. Showing them
# unticked would be a lie: tools.enabled_tools treats absent as on
# when `tools` is on, so that an upgrade does not silently take web
@@ -342,6 +354,55 @@ async def move_model(
return RedirectResponse(back or "/admin/models", status_code=303)
@router.post("/admin/models/{model_id}/detect-efforts")
async def detect_efforts(db: Db, user: AdminUser, model_id: str) -> Response:
"""Ask the endpoint which reasoning efforts this model actually takes.
llama-server hands its loaded model's Jinja chat template over on `/props`,
and that template is the thing that rejects an effort it does not know -- so
the accepted set is written down in the one place that is authoritative,
rather than having to be guessed at or discovered by a failed reply.
Anything that is not a llama-server answers nothing here, and that is a
normal outcome: OpenAI and vLLM have no such route, and their models are
documented rather than introspectable. The result then says so instead of
claiming the model accepts nothing.
"""
from lembas.services.llm.openai_client import Endpoint, fetch_chat_template
model = _model(db, model_id)
connection = db.get(Connection, model.connection_id)
if connection is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
template = await fetch_chat_template(Endpoint.from_connection(connection))
found = chat_service.efforts_from_chat_template(template)
if found:
model.reasoning_efforts = found
db.commit()
message = "This model's template accepts: " + ", ".join(found) + "."
kind = "success"
elif template:
message = (
"The endpoint gave up its chat template, but nothing in it names a "
"set of reasoning efforts. Either this model does not take one, or "
"it accepts anything and never checks."
)
kind = "warning"
else:
message = (
"This endpoint does not publish its chat template, so there is "
"nothing to read. llama.cpp does; OpenAI and vLLM do not."
)
kind = "warning"
return RedirectResponse(
f"/admin/models/{model.id}/edit?detected={kind}&message={quote(message)}",
status_code=status.HTTP_303_SEE_OTHER,
)
@router.post("/admin/models/{model_id}/default")
async def set_default_model(
db: Db, user: AdminUser, model_id: str, back: str = Form("")
+61
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -470,6 +471,66 @@ def resolved_effort(chat) -> str:
return value if value in EFFORTS else ""
def efforts_from_chat_template(template: str) -> list[str]:
"""Which efforts a model's Jinja chat template will actually accept.
The template is where the truth lives: the one on a Bonsai reads roughly
{%- if reasoning_effort not in ('xhigh', 'medium', 'low') %}
{{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ...
so the accepted set is written out beside the thing that rejects everything
else. `llama-server` hands the whole template over on `/props`, which makes
this readable rather than guessable.
Deliberately conservative, because a wrong answer here silently removes a
level somebody is entitled to:
- only quoted literals within a short window of a `reasoning_effort`
mention are considered, so an unrelated list elsewhere in a four-hundred
line template cannot contribute;
- the result is intersected with `EFFORTS`, so an unknown token is dropped
rather than stored;
- fewer than two survivors is treated as "the template did not say". One
match is far more likely to be a default assignment
(`{%- set reasoning_effort = 'medium' %}`) than a vocabulary.
Returns [] when nothing can be read, which every caller treats as "ask
somebody" rather than as "this model accepts nothing".
"""
if not template or "reasoning_effort" not in template:
return []
found: set[str] = set()
# Shape one: the values sit in the statement that tests them.
# {%- if reasoning_effort not in ('xhigh', 'medium', 'low') %}
for match in re.finditer(r"reasoning_effort", template):
window = template[match.start() : match.start() + 400]
# Stop at the end of the statement that mentions it, so a later,
# unrelated block cannot leak in.
window = window.split("%}")[0] if "%}" in window else window
for literal in re.findall(r"""['"]([a-z]{3,8})['"]""", window):
if literal in EFFORTS:
found.add(literal)
# Shape two: the values are a named list somewhere else, and the test says
# {%- if reasoning_effort not in valid_efforts %}
# so nothing near the mention names them. Any group of quoted literals in
# which *every* token is a known effort and there are at least two is taken
# -- that is a strong enough signal on its own, and a list of nothing but
# effort names that is not the effort vocabulary would be a strange thing
# for a chat template to contain.
for group in re.findall(r"[\[(]((?:\s*['\"][a-z]{3,8}['\"]\s*,?)+)[\])]", template):
literals = re.findall(r"""['"]([a-z]{3,8})['"]""", group)
if len(literals) >= 2 and all(value in EFFORTS for value in literals):
found.update(literals)
if len(found) < 2:
return []
return [effort for effort in EFFORTS if effort in found]
def apply_effort(
body: dict[str, Any], effort: str | None, supported: tuple[str, ...] | None = None
) -> None:
+37
View File
@@ -68,6 +68,19 @@ class Endpoint:
base = f"{base}/v1"
return f"{base}/{path.lstrip('/')}"
def root_url(self, path: str) -> str:
"""A URL at the *server's* root rather than under `/v1`.
llama-server's own endpoints -- `/props` is the one that matters here --
sit beside the OpenAI-compatible surface, not inside it. A base URL may
be written either way (`http://host:8080` or `.../v1`), so the suffix is
stripped rather than assumed absent.
"""
base = self.base_url.rstrip("/")
if base.endswith("/v1"):
base = base[: -len("/v1")]
return f"{base}/{path.lstrip('/')}"
def headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json", **self.extra_headers}
# Local endpoints frequently need no key at all; sending an empty
@@ -77,6 +90,30 @@ class Endpoint:
return headers
async def fetch_chat_template(endpoint: Endpoint) -> str:
"""The model's own Jinja chat template, from llama-server's `/props`.
The one place the truth about a model's accepted values is actually
written down: `/props` returns `chat_template` verbatim, and that template
is what raises when it meets a `reasoning_effort` it does not know.
Returns "" rather than raising for anything that is not a llama-server --
OpenAI, vLLM and the rest have no such route, and "this endpoint cannot
tell us" is a normal answer here, not a failure.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
endpoint.root_url("props"), headers=endpoint.headers()
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError, json.JSONDecodeError):
return ""
template = payload.get("chat_template") if isinstance(payload, dict) else ""
return template if isinstance(template, str) else ""
def describe_http_error(exc: httpx.HTTPStatusError) -> str:
"""Turn an upstream error response into something worth reading.
@@ -115,6 +115,31 @@
</label>
{% endfor %}
</div>
{% if detected %}
<div class="alert alert--{{ 'success' if detected == 'success' else 'warning' }}"
role="status">
{{ icon('sparkle' if detected == 'success' else 'warning', 'alert__icon') }}
<span>{{ detected_message }}</span>
</div>
{% endif %}
{#
Reading the answer rather than asking somebody to know it. llama-server
publishes the loaded model's Jinja chat template on `/props`, and that
template is the thing that rejects an effort it does not recognise --
so the accepted set is written down in the one authoritative place.
Endpoints without that route (OpenAI, vLLM) say so rather than
pretending the model accepts nothing.
Its own form, because this page's main form is a PUT of everything and
a detect must not carry half-edited fields with it.
#}
<form method="post" action="/admin/models/{{ model.id }}/detect-efforts">
<button class="btn btn--sm" type="submit">
{{ icon('search', 'icon--sm') }} Detect from the endpoint
</button>
</form>
<p class="field__hint">
The vocabulary is <strong>not the same for every model</strong>, and
sending one a model does not know is not ignored — it is rendered into
+126
View File
@@ -456,3 +456,129 @@ 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.") == []
# --- Reading the answer instead of asking somebody to know it ----------------
#
# llama-server publishes the loaded model's Jinja chat template on /props, and
# that template is the thing that rejects an effort it does not know -- so the
# accepted set is written down in the one authoritative place.
BONSAI_TEMPLATE = (
"{%- if reasoning_effort not in ('xhigh', 'medium', 'low') %}"
"{{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ "
"'. Supported types are xhigh (default), medium, and low.') }}{%- endif %}"
)
GPT_OSS_TEMPLATE = (
'{%- set valid_efforts = ["low", "medium", "high"] %}'
"{%- if reasoning_effort not in valid_efforts %}"
"{{ raise_exception('bad effort') }}{% endif %}"
)
def test_the_accepted_set_is_read_out_of_the_template():
from lembas.services import chat as chat_service
assert chat_service.efforts_from_chat_template(BONSAI_TEMPLATE) == [
"low", "medium", "xhigh",
]
def test_a_template_that_keeps_its_list_in_a_variable_is_read_too():
"""gpt-oss names the list rather than inlining it, so nothing near the
`reasoning_effort` mention spells the values out."""
from lembas.services import chat as chat_service
assert chat_service.efforts_from_chat_template(GPT_OSS_TEMPLATE) == [
"low", "medium", "high",
]
def test_an_unrelated_list_is_not_mistaken_for_a_vocabulary():
from lembas.services import chat as chat_service
template = '{%- set roles = ["user", "assistant", "system"] %}{{ messages }}'
assert chat_service.efforts_from_chat_template(template) == []
def test_a_single_mention_is_not_a_vocabulary():
"""`{%- set reasoning_effort = 'medium' %}` is a default, not a list, and
reading it as one would leave a model offering exactly one level."""
from lembas.services import chat as chat_service
assert chat_service.efforts_from_chat_template("{%- set reasoning_effort = 'medium' %}") == []
def test_a_template_that_says_nothing_says_nothing():
from lembas.services import chat as chat_service
assert chat_service.efforts_from_chat_template("") == []
assert chat_service.efforts_from_chat_template("{{ messages }}") == []
def test_props_lives_beside_the_openai_surface_not_inside_it():
"""`/props` is llama-server's own route, at the server root -- a base URL
written as `.../v1` would otherwise ask for `/v1/props`, which is a 404."""
from lembas.services.llm.openai_client import Endpoint
endpoint = Endpoint(base_url="http://host:8080/v1", api_key="", extra_headers={})
assert endpoint.root_url("props") == "http://host:8080/props"
bare = Endpoint(base_url="http://host:8080", api_key="", extra_headers={})
assert bare.root_url("props") == "http://host:8080/props"
# And the OpenAI surface is unchanged by any of this.
assert bare.url("chat/completions") == "http://host:8080/v1/chat/completions"
def test_detecting_from_the_endpoint_writes_the_list(client, db, registered, mock_http):
"""The whole path: a button, a GET to /props, the template parsed, the
model's list written."""
import httpx
from sqlalchemy import select
from lembas.db.models import Connection, Model
connection = Connection(name="local", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="bonsai"))
db.commit()
model = db.scalar(select(Model).where(Model.model_id == "bonsai"))
asked: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
asked.append(str(request.url))
return httpx.Response(200, json={"chat_template": BONSAI_TEMPLATE})
mock_http(handler)
response = client.post(
f"/admin/models/{model.id}/detect-efforts", follow_redirects=False
)
assert response.status_code == 303
db.expire_all()
assert db.get(Model, model.id).reasoning_efforts == ["low", "medium", "xhigh"]
# At the server root, not under /v1.
assert asked and asked[0].endswith("/props")
def test_an_endpoint_with_no_props_leaves_the_list_alone(client, db, registered, mock_http):
"""OpenAI and vLLM have no such route, and "this cannot tell us" must not
be recorded as "this model accepts nothing"."""
import httpx
from sqlalchemy import select
from lembas.db.models import Connection, Model
connection = Connection(name="hosted", base_url="http://127.0.0.1:2", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="gpt-x", reasoning_efforts=["low", "high"]))
db.commit()
model = db.scalar(select(Model).where(Model.model_id == "gpt-x"))
mock_http(lambda request: httpx.Response(404, json={"error": "not found"}))
client.post(f"/admin/models/{model.id}/detect-efforts", follow_redirects=False)
db.expire_all()
assert db.get(Model, model.id).reasoning_efforts == ["low", "high"]