diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f94f2d..b6a4e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 62184fc..005e1a0 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py index 9343975..eedcd5a 100644 --- a/src/lembas/api/admin_models.py +++ b/src/lembas/api/admin_models.py @@ -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("") diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py index eb82617..3432c51 100644 --- a/src/lembas/services/chat.py +++ b/src/lembas/services/chat.py @@ -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: diff --git a/src/lembas/services/llm/openai_client.py b/src/lembas/services/llm/openai_client.py index 70c7c4b..743f5f9 100644 --- a/src/lembas/services/llm/openai_client.py +++ b/src/lembas/services/llm/openai_client.py @@ -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. diff --git a/src/lembas/web/templates/admin/model_detail.html b/src/lembas/web/templates/admin/model_detail.html index 76e1a04..29c9e66 100644 --- a/src/lembas/web/templates/admin/model_detail.html +++ b/src/lembas/web/templates/admin/model_detail.html @@ -115,6 +115,31 @@ {% endfor %} + {% if detected %} +
The vocabulary is not the same for every model, and sending one a model does not know is not ignored — it is rendered into diff --git a/tests/test_effort.py b/tests/test_effort.py index 5ecaf9a..61029b4 100644 --- a/tests/test_effort.py +++ b/tests/test_effort.py @@ -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"]