Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1dbca7db6
|
||
|
|
32e2326d41
|
@@ -16,6 +16,41 @@ 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
|
||||
Jinja traceback where the answer should have been. Reasoning effort is sent
|
||||
two ways, and the second — `chat_template_kwargs` — is rendered into the
|
||||
model's own chat template, which does not ignore a value it has never heard
|
||||
of: it raises, and the whole request fails. The catch is that the vocabulary
|
||||
is **not the same for every model**. gpt-oss takes `low/medium/high`; Bonsai
|
||||
takes `low/medium/xhigh` and refuses `high`; OpenAI has added `minimal`,
|
||||
`xhigh` and `max` at various points. This application offered the same three
|
||||
to everything, so on some models the top setting was one the model would
|
||||
throw for.
|
||||
- **A model now has its own list of the efforts it accepts**, on its page under
|
||||
Models, and the composer's picker and `/effort` offer only those. Tick none
|
||||
and the familiar three are used, which is right for nearly everything.
|
||||
- **And it corrects itself.** If an endpoint refuses an effort anyway — a model
|
||||
swapped underneath a name, a runtime upgraded — that reply is retried once
|
||||
without it instead of being lost, and the model's list is narrowed so the
|
||||
menu stops offering something that does not work. Where the endpoint says
|
||||
what it *does* take, that is what gets stored.
|
||||
- `/effort` now reads the levels from the picker rather than from a second copy
|
||||
of the list kept in the browser, so the two can no longer disagree about what
|
||||
a valid effort is.
|
||||
|
||||
## 1.1.2
|
||||
|
||||
Two things a phone found that 1.1.0's phone pass had not.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.1.2"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
@@ -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)
|
||||
@@ -177,7 +184,16 @@ async def model_detail(
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"capabilities": PROTOCOL_CAPABILITIES,
|
||||
"tool_capabilities": TOOL_CAPABILITIES,
|
||||
# Every effort this application understands, so an administrator
|
||||
# can tick the ones their model actually takes -- and the model's
|
||||
# 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
|
||||
@@ -238,6 +254,7 @@ async def update_model(
|
||||
position: str = Form(""),
|
||||
context_length: str = Form(""),
|
||||
default_effort: str = Form(""),
|
||||
reasoning_efforts: list[str] = Form(default=[]),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
capability: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
@@ -260,9 +277,19 @@ async def update_model(
|
||||
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
||||
# holds whatever sampling defaults an administrator has set and this form
|
||||
# only carries one of them.
|
||||
# Which efforts this model takes at all. Submitted as a list of ticked
|
||||
# values; empty means "nobody has said", and `chat.efforts_for` answers with
|
||||
# the common three. Stored in the order `EFFORTS` declares rather than the
|
||||
# order a browser happened to send.
|
||||
chosen = [value for value in chat_service.EFFORTS if value in (reasoning_efforts or [])]
|
||||
model.reasoning_efforts = chosen
|
||||
|
||||
params = dict(model.params_json or {})
|
||||
wanted = default_effort.strip().lower()
|
||||
if wanted in chat_service.EFFORTS:
|
||||
# Checked against what this model takes, not against everything this
|
||||
# application has heard of -- a default of `high` on a model whose template
|
||||
# refuses it is a chat that fails on its first turn.
|
||||
if wanted in chat_service.efforts_for(model):
|
||||
params["reasoning_effort"] = wanted
|
||||
else:
|
||||
params.pop("reasoning_effort", None)
|
||||
@@ -327,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("")
|
||||
|
||||
@@ -82,10 +82,12 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
else []
|
||||
),
|
||||
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
||||
# The three a reasoning model understands. From the service so the
|
||||
# command, the control and the request builder cannot disagree about
|
||||
# what is a valid effort.
|
||||
"efforts": chat_service.EFFORTS,
|
||||
# What *this* model takes, not the three every model used to be assumed
|
||||
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
|
||||
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
||||
# raises inside the chat template and fails the reply. From the service
|
||||
# so the command, the control and the request builder cannot disagree.
|
||||
"efforts": chat_service.efforts_for(current) if current else chat_service.DEFAULT_EFFORTS,
|
||||
# What the picker shows, and what `build_request` will send. One
|
||||
# resolver so the two cannot disagree.
|
||||
"resolved_effort": chat_service.resolved_effort(chat) if chat else "",
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
from lembas.db.types import JSONDict, JSONList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Import only for the annotation; at runtime SQLAlchemy resolves the
|
||||
@@ -137,6 +137,20 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
|
||||
# ticked anything.
|
||||
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
# Which reasoning efforts this model actually accepts. Empty means "nobody
|
||||
# has said", and `services/chat.efforts_for` answers with the common set.
|
||||
#
|
||||
# It has to be per model, because the vocabulary is: gpt-oss takes
|
||||
# low/medium/high, Bonsai takes low/medium/xhigh and *raises* on high, and
|
||||
# OpenAI's own list has grown minimal, xhigh and max at different times. A
|
||||
# single global tuple is a guess that is wrong for somebody.
|
||||
#
|
||||
# ⚠ A column and not a key in `capabilities_json`, for exactly the reason
|
||||
# `context_length` is one: that dict is rebuilt wholesale from the submitted
|
||||
# checkboxes on every save, so anything in it that is not a checkbox is
|
||||
# destroyed the next time an administrator ticks anything.
|
||||
reasoning_efforts: Mapped[list[str]] = mapped_column(JSONList, default=list)
|
||||
|
||||
connection: Mapped[Connection] = relationship(back_populates="models")
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=model_groups, back_populates="models"
|
||||
|
||||
+119
-5
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -387,7 +388,15 @@ def build_request(
|
||||
):
|
||||
body["tool_choice"] = {"type": "function", "function": {"name": force_tool}}
|
||||
|
||||
apply_effort(body, (chat.params_json or {}).get("reasoning_effort"))
|
||||
# The model's own vocabulary, looked up here rather than passed in: every
|
||||
# caller of `build_request` would otherwise have to remember, which is the
|
||||
# trap `audio_service.template_flags` fell into.
|
||||
chat_model = model_for(db, chat)
|
||||
apply_effort(
|
||||
body,
|
||||
(chat.params_json or {}).get("reasoning_effort"),
|
||||
efforts_for(chat_model) if chat_model is not None else None,
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
@@ -405,7 +414,42 @@ def build_request(
|
||||
# an effort on sends neither field and is byte-for-byte what it was. An endpoint
|
||||
# strict about unknown parameters will refuse the extra one -- but on a chat
|
||||
# somebody deliberately set an effort on, not on every chat in the instance.
|
||||
EFFORTS = ("low", "medium", "high")
|
||||
# Every reasoning effort this application understands, and the subset a model
|
||||
# gets when nobody has said otherwise.
|
||||
#
|
||||
# 🚨 These are two different questions and conflating them is what broke a
|
||||
# chat on Bonsai: `EFFORTS` was `("low", "medium", "high")` and was used both to
|
||||
# validate what somebody chose *and* to decide what to offer, so a model whose
|
||||
# vocabulary is low/medium/**xhigh** could not be given its own top setting,
|
||||
# and the one it was given -- `high` -- made its chat template call
|
||||
# `raise_exception` and took the whole reply with it.
|
||||
#
|
||||
# The known list is the union across providers, which have not agreed: OpenAI
|
||||
# has added `minimal`, `xhigh` and `max` at different points; gpt-oss takes
|
||||
# low/medium/high; Bonsai takes low/medium/xhigh and refuses high. `none` is
|
||||
# deliberately absent -- this application already spells that `off`, and two
|
||||
# spellings of off is the failure this codebase keeps cataloguing.
|
||||
EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")
|
||||
|
||||
# What a model is offered when its own list is empty. The three every reasoning
|
||||
# model since the first one has understood.
|
||||
DEFAULT_EFFORTS = ("low", "medium", "high")
|
||||
|
||||
|
||||
def efforts_for(model) -> tuple[str, ...]:
|
||||
"""The efforts this model accepts, in the order they should be offered.
|
||||
|
||||
A model's own list when an administrator has set one or the endpoint has
|
||||
taught us one (see `generation._narrow_efforts`), and the common three
|
||||
otherwise. Filtered against `EFFORTS` on the way out, so a value stored by
|
||||
an older release -- or learned from an endpoint that advertised something
|
||||
this application has never heard of -- cannot reach a request body.
|
||||
"""
|
||||
stored = list(getattr(model, "reasoning_efforts", None) or [])
|
||||
chosen = [value for value in stored if value in EFFORTS]
|
||||
if not chosen:
|
||||
return DEFAULT_EFFORTS
|
||||
return tuple(value for value in EFFORTS if value in chosen)
|
||||
|
||||
|
||||
def resolved_effort(chat) -> str:
|
||||
@@ -427,9 +471,79 @@ def resolved_effort(chat) -> str:
|
||||
return value if value in EFFORTS else ""
|
||||
|
||||
|
||||
def apply_effort(body: dict[str, Any], effort: str | None) -> None:
|
||||
"""Put a chosen reasoning effort into a request body, in both forms."""
|
||||
if not effort or effort not in EFFORTS:
|
||||
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:
|
||||
"""Put a chosen reasoning effort into a request body, in both forms.
|
||||
|
||||
`supported` is the model's own vocabulary. An effort outside it is dropped
|
||||
rather than sent, because the second form below is not advisory: it reaches
|
||||
the model's Jinja chat template, and a template that does not know the value
|
||||
raises rather than ignoring it -- which fails the whole request, not the
|
||||
parameter.
|
||||
"""
|
||||
allowed = supported or DEFAULT_EFFORTS
|
||||
if not effort or effort not in allowed:
|
||||
return
|
||||
body["reasoning_effort"] = effort
|
||||
kwargs = dict(body.get("chat_template_kwargs") or {})
|
||||
|
||||
@@ -19,6 +19,7 @@ import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
@@ -454,6 +455,122 @@ def _narrower(instance: float, quota: int) -> float:
|
||||
return float(min(instance, quota))
|
||||
|
||||
|
||||
# --- A reasoning effort the model will not take ------------------------------
|
||||
#
|
||||
# `chat_template_kwargs.reasoning_effort` is not advisory. It reaches the
|
||||
# model's Jinja chat template, and a template that does not know the value does
|
||||
# not ignore it -- gpt-oss and Bonsai both call `raise_exception`, which fails
|
||||
# the whole request. The reader sees their reply die with a Jinja traceback in
|
||||
# it, having chosen a perfectly ordinary-looking option from a menu this
|
||||
# application drew.
|
||||
#
|
||||
# So the value is checked against the model's own vocabulary before it is sent
|
||||
# (`chat.apply_effort`), and this is the second line: when it is refused anyway
|
||||
# -- an endpoint upgraded underneath us, a model whose list nobody has set --
|
||||
# the reply is retried once without it rather than lost, and the model's list is
|
||||
# narrowed so the menu stops offering something that does not work.
|
||||
|
||||
|
||||
def _effort_was_refused(message: str) -> bool:
|
||||
"""Whether this error is the chat template refusing the effort we sent.
|
||||
|
||||
Deliberately narrow. Anything that merely mentions reasoning would also
|
||||
match a model politely declining to think, and retrying *that* silently
|
||||
would hide a real failure behind a second request.
|
||||
"""
|
||||
lowered = message.lower()
|
||||
return "effort" in lowered and ("unexpected" in lowered or "supported" in lowered)
|
||||
|
||||
|
||||
def _advertised_efforts(message: str) -> list[str]:
|
||||
"""The efforts an error message says it will take, if it says.
|
||||
|
||||
Bonsai's is "Unexpected reasoning effort high. Supported types are xhigh
|
||||
(default), medium, and low." -- which is the answer, written out, in the
|
||||
failure. Read only from the part after "supported", so the *rejected* value
|
||||
named in the first sentence is not collected as a supported one.
|
||||
|
||||
Best-effort by design: it only ever narrows what is offered, an
|
||||
administrator can set the list by hand, and anything unrecognised is
|
||||
dropped by `efforts_for` on the way out.
|
||||
"""
|
||||
lowered = message.lower()
|
||||
if "supported" not in lowered:
|
||||
return []
|
||||
tail = lowered.split("supported", 1)[1]
|
||||
# Whole words. `"high" in "xhigh"` is true, so a substring test reads
|
||||
# Bonsai's "Supported types are xhigh (default), medium, and low" as
|
||||
# advertising `high` -- the very value it has just refused -- and the list
|
||||
# would learn the opposite of what the endpoint said.
|
||||
words = set(re.findall(r"[a-z]+", tail))
|
||||
return [effort for effort in chat_service.EFFORTS if effort in words]
|
||||
|
||||
|
||||
def _learn_refused_effort(model_id: str, refused: str, message: str) -> None:
|
||||
"""Write what the endpoint just taught us onto the model.
|
||||
|
||||
Its own session: this runs from inside a generation, which outlives the
|
||||
request's session, and the whole point is that it survives to the next turn.
|
||||
"""
|
||||
from lembas.db.models import Model
|
||||
|
||||
if not model_id:
|
||||
return
|
||||
try:
|
||||
with session_scope() as db:
|
||||
models = list(db.scalars(select(Model).where(Model.model_id == model_id)))
|
||||
for model in models:
|
||||
advertised = _advertised_efforts(message)
|
||||
current = list(model.reasoning_efforts or chat_service.DEFAULT_EFFORTS)
|
||||
# What the endpoint advertised, when it did; otherwise simply
|
||||
# the list it had, minus the one it has just refused.
|
||||
wanted = advertised or [e for e in current if e != refused]
|
||||
wanted = [e for e in wanted if e in chat_service.EFFORTS and e != refused]
|
||||
if wanted and wanted != list(model.reasoning_efforts or []):
|
||||
model.reasoning_efforts = wanted
|
||||
log.info(
|
||||
"model %s refused reasoning effort %r; efforts narrowed to %s",
|
||||
model_id, refused, wanted,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never let bookkeeping fail a reply
|
||||
log.exception("could not record the refused effort for model %s", model_id)
|
||||
|
||||
|
||||
async def _stream_once(endpoint, payload, generation, model_id: str):
|
||||
"""`stream_chat`, retried once without the reasoning effort if that is what
|
||||
the endpoint objected to.
|
||||
|
||||
⚠ The retry is only safe because the template is rendered *before* any token
|
||||
is produced, so a refusal arrives with nothing yet emitted. `sent` is the
|
||||
guard that keeps it that way: once a single chunk has reached the caller,
|
||||
the reply is under way and a second request would duplicate it.
|
||||
"""
|
||||
sent = False
|
||||
try:
|
||||
async for chunk in stream_chat(endpoint, payload):
|
||||
sent = True
|
||||
yield chunk
|
||||
return
|
||||
except LLMError as exc:
|
||||
refused = str((payload.get("chat_template_kwargs") or {}).get("reasoning_effort") or "")
|
||||
if sent or not refused or not _effort_was_refused(exc.message):
|
||||
raise
|
||||
log.info("retrying without reasoning effort %r: %s", refused, exc.message)
|
||||
_learn_refused_effort(model_id, refused, exc.message)
|
||||
|
||||
retry = dict(payload)
|
||||
retry.pop("reasoning_effort", None)
|
||||
kwargs = dict(retry.get("chat_template_kwargs") or {})
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
if kwargs:
|
||||
retry["chat_template_kwargs"] = kwargs
|
||||
else:
|
||||
retry.pop("chat_template_kwargs", None)
|
||||
|
||||
async for chunk in stream_chat(endpoint, retry):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _run(generation: Generation) -> None:
|
||||
"""Produce one reply, then persist it. Never raises into the task.
|
||||
|
||||
@@ -643,7 +760,7 @@ async def _run(generation: Generation) -> None:
|
||||
# round thinks at all -- plenty of rounds do not.
|
||||
round_thinking: tuple[float, float] | None = None
|
||||
|
||||
async for chunk in stream_chat(endpoint, payload):
|
||||
async for chunk in _stream_once(endpoint, payload, generation, model_id):
|
||||
counts = chunk_usage(chunk)
|
||||
if counts is not None:
|
||||
generation.reported_usage = True
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -271,8 +271,21 @@
|
||||
|
||||
/* --- Reasoning effort ---------------------------------------------------
|
||||
The command drives the same select the composer shows, so there is one
|
||||
piece of state and the control updates itself when the command is used. */
|
||||
var EFFORTS = ["low", "medium", "high"];
|
||||
piece of state and the control updates itself when the command is used.
|
||||
|
||||
Which efforts exist is read off that select's own options rather than
|
||||
kept here. It used to be a second copy of `["low","medium","high"]`, which
|
||||
was wrong the moment the vocabulary became per model: a Bonsai takes
|
||||
`xhigh` and no `high`, so the list the server rendered and the list this
|
||||
file believed in disagreed -- and the one that decides what `/effort xhigh`
|
||||
does was this one. The select is the table; nothing else should hold it. */
|
||||
function efforts() {
|
||||
var select = el("[data-effort]");
|
||||
if (!select) return [];
|
||||
return Array.prototype.map
|
||||
.call(select.options, function (option) { return option.value; })
|
||||
.filter(function (value) { return value !== "off"; });
|
||||
}
|
||||
|
||||
function setEffort(rest) {
|
||||
var select = el("[data-effort]");
|
||||
@@ -283,12 +296,14 @@
|
||||
"error"
|
||||
);
|
||||
}
|
||||
var available = efforts();
|
||||
var listed = available.join(", ");
|
||||
var wanted = (rest || "").trim().toLowerCase();
|
||||
if (!wanted) {
|
||||
return note(
|
||||
EFFORTS.indexOf(select.value) === -1
|
||||
? "No effort is being sent. Try low, medium or high."
|
||||
: "Effort is " + select.value + ". /effort low, medium, high, or off."
|
||||
available.indexOf(select.value) === -1
|
||||
? "No effort is being sent. Try " + listed + "."
|
||||
: "Effort is " + select.value + ". /effort " + listed + ", or off."
|
||||
);
|
||||
}
|
||||
/* "off" is the option's real value, not an empty string: the new-chat form
|
||||
@@ -296,8 +311,11 @@
|
||||
sentinel and this has to match it. "default" and "none" still work,
|
||||
because somebody's fingers will type them. */
|
||||
if (wanted === "default" || wanted === "none") wanted = "off";
|
||||
else if (wanted !== "off" && EFFORTS.indexOf(wanted) === -1) {
|
||||
return note("“" + wanted + "” is not an effort. Try low, medium, high or off.", "error");
|
||||
else if (wanted !== "off" && available.indexOf(wanted) === -1) {
|
||||
return note(
|
||||
"“" + wanted + "” is not an effort this model takes. Try " + listed + " or off.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
select.value = wanted;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
@@ -104,11 +104,64 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Reasoning efforts this model accepts</span>
|
||||
<div class="btn-row">
|
||||
{% for value in efforts %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="reasoning_efforts" value="{{ value }}"
|
||||
{{ 'checked' if value in model_efforts }}>
|
||||
<span class="mono">{{ value }}</span>
|
||||
</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
|
||||
the model's chat template, which raises and fails the whole reply.
|
||||
gpt-oss takes <span class="mono">low/medium/high</span>; Bonsai takes
|
||||
<span class="mono">low/medium/xhigh</span> and refuses
|
||||
<span class="mono">high</span>; OpenAI has added
|
||||
<span class="mono">minimal</span>, <span class="mono">xhigh</span> and
|
||||
<span class="mono">max</span> at various points.
|
||||
<br>
|
||||
Tick none and the common three are offered, which is right for almost
|
||||
everything. If an endpoint ever refuses one anyway, that reply is
|
||||
retried without it and this list corrects itself — so this is worth
|
||||
setting by hand only to save that one round trip.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
||||
<select class="select" id="default-effort" name="default_effort">
|
||||
<option value="">None — send nothing</option>
|
||||
{% for value in efforts %}
|
||||
{% for value in model_efforts %}
|
||||
<option value="{{ value }}"
|
||||
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
||||
{{ value }}
|
||||
|
||||
@@ -366,3 +366,219 @@ def test_the_picker_never_says_default(client: TestClient, db, registered):
|
||||
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.") == []
|
||||
|
||||
|
||||
# --- 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"]
|
||||
|
||||
Reference in New Issue
Block a user