Map Hermes reasoning controls into Qwen templates
This commit is contained in:
@@ -89,6 +89,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"total_tokens": 15},
|
||||
"mock_ctx": current_ctx(),
|
||||
"mock_authorization": self.headers.get("Authorization"),
|
||||
"mock_reasoning_effort": body.get("reasoning_effort"),
|
||||
"mock_chat_template_kwargs": body.get("chat_template_kwargs"),
|
||||
}
|
||||
|
||||
def _stream(self, body: dict) -> None:
|
||||
|
||||
@@ -156,6 +156,25 @@ assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d
|
||||
assert d.get("mock_authorization") is None, d
|
||||
' && ok "Request wurde weitergeleitet, Modell ersetzt" || bad "Forwarding"
|
||||
|
||||
echo "== Test 3b: Hermes-Reasoning erreicht das llama.cpp-Chat-Template"
|
||||
RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||
-d '{"model":"qwen-fast","reasoning_effort":"none","messages":[{"role":"user","content":"Hallo"}]}')
|
||||
echo "$RESP" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
assert d.get("mock_reasoning_effort") is None, d
|
||||
assert d.get("mock_chat_template_kwargs") == {"enable_thinking": False}, d
|
||||
' && ok "none wird als enable_thinking=false weitergegeben" || bad "Reasoning none"
|
||||
RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||
-d '{"model":"qwen-fast","reasoning_effort":"medium","messages":[{"role":"user","content":"Hallo"}]}')
|
||||
echo "$RESP" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
assert d.get("mock_reasoning_effort") is None, d
|
||||
assert d.get("mock_chat_template_kwargs") == {
|
||||
"enable_thinking": True, "reasoning_effort": "medium"}, d
|
||||
' && ok "medium erreicht chat_template_kwargs" || bad "Reasoning medium"
|
||||
|
||||
# --- 4. Streaming ----------------------------------------------------------------
|
||||
echo "== Test 4: Streaming (SSE)"
|
||||
RESP=$(curl -sfN "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||
|
||||
@@ -27,6 +27,7 @@ from ai_profile_router import ( # noqa: E402
|
||||
_context_matches,
|
||||
_normalize_chat_image,
|
||||
_normalize_chat_images,
|
||||
_normalize_llamacpp_reasoning,
|
||||
_request_has_image,
|
||||
)
|
||||
|
||||
@@ -126,6 +127,55 @@ class ChatImageInputTests(unittest.TestCase):
|
||||
self.assertEqual(request, normalized)
|
||||
|
||||
|
||||
class LlamaCppReasoningTests(unittest.TestCase):
|
||||
def test_none_really_disables_thinking(self) -> None:
|
||||
request = {"reasoning_effort": "none", "messages": []}
|
||||
normalized = _normalize_llamacpp_reasoning(request)
|
||||
self.assertNotIn("reasoning_effort", normalized)
|
||||
self.assertEqual(
|
||||
normalized["chat_template_kwargs"],
|
||||
{"enable_thinking": False},
|
||||
)
|
||||
|
||||
def test_low_and_medium_reach_chat_template(self) -> None:
|
||||
for effort in ("low", "medium"):
|
||||
with self.subTest(effort=effort):
|
||||
request = {"reasoning_effort": effort, "messages": []}
|
||||
normalized = _normalize_llamacpp_reasoning(request)
|
||||
self.assertEqual(normalized["chat_template_kwargs"], {
|
||||
"enable_thinking": True,
|
||||
"reasoning_effort": effort,
|
||||
})
|
||||
|
||||
def test_unsupported_high_levels_are_clamped_to_xhigh(self) -> None:
|
||||
for effort in ("high", "xhigh", "max", "ultra"):
|
||||
with self.subTest(effort=effort):
|
||||
request = {"reasoning_effort": effort, "messages": []}
|
||||
normalized = _normalize_llamacpp_reasoning(request)
|
||||
self.assertEqual(
|
||||
normalized["chat_template_kwargs"]["reasoning_effort"],
|
||||
"xhigh",
|
||||
)
|
||||
|
||||
def test_existing_template_kwargs_are_preserved(self) -> None:
|
||||
request = {
|
||||
"reasoning_effort": "low",
|
||||
"chat_template_kwargs": {"preserve_thinking": True},
|
||||
"messages": [],
|
||||
}
|
||||
normalized = _normalize_llamacpp_reasoning(request)
|
||||
self.assertEqual(normalized["chat_template_kwargs"], {
|
||||
"preserve_thinking": True,
|
||||
"enable_thinking": True,
|
||||
"reasoning_effort": "low",
|
||||
})
|
||||
|
||||
def test_request_without_effort_is_unchanged(self) -> None:
|
||||
request = {"messages": []}
|
||||
self.assertIs(_normalize_llamacpp_reasoning(request), request)
|
||||
self.assertNotIn("chat_template_kwargs", request)
|
||||
|
||||
|
||||
class RetentionTests(unittest.TestCase):
|
||||
def test_oldest_pairs_are_removed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
|
||||
@@ -78,6 +78,13 @@ Zielplattform.
|
||||
Der Router übernimmt:
|
||||
|
||||
- OpenAI-kompatibles Chat-Proxying und Streaming
|
||||
- Übersetzung von OpenAI-/Hermes-`reasoning_effort` in die vom
|
||||
Qwen3.8-Jinja-Template tatsächlich ausgewerteten
|
||||
`chat_template_kwargs`: `none` deaktiviert Thinking mit
|
||||
`enable_thinking: false`; `low` und `medium` bleiben erhalten; höhere
|
||||
Client-Stufen werden auf das vom Modell unterstützte `xhigh` begrenzt.
|
||||
Das ist absichtlich zentral im Router implementiert, damit Hermes,
|
||||
OpenWebUI und weitere OpenAI-kompatible Clients identisches Verhalten haben.
|
||||
- virtuelle Modelle und automatische Profilumschaltung
|
||||
- Tool Calls
|
||||
- direkte integrierte Vision in Fast, Medium, Large und Uncensored
|
||||
@@ -98,8 +105,10 @@ Der Router übernimmt:
|
||||
- Die Kompressionszusammenfassung nutzt weiterhin dasselbe aktive Modell. Ein
|
||||
kleineres Fast-Modell wäre zwar schneller, besitzt aber nicht genug Kontext,
|
||||
um die vollständige Mitte einer Medium-, Large- oder Ultra-Sitzung sicher zu
|
||||
verarbeiten. `reasoning_effort: none` vermeidet unnötiges Nachdenken beim
|
||||
reinen Zusammenfassen.
|
||||
verarbeiten. `reasoning_effort: none` wird vom Router in
|
||||
`enable_thinking: false` übersetzt und vermeidet damit nachweislich
|
||||
unnötiges Nachdenken beim reinen Zusammenfassen. Ein unverändert an
|
||||
llama.cpp gesendetes Top-Level-`reasoning_effort` wäre wirkungslos.
|
||||
- `Summarizing thread` ist eine echte zusätzliche Modellanfrage. Bei sehr alten
|
||||
Sitzungen kann die Desktop-Anzeige nach abgeschlossener Kompression außerdem
|
||||
veraltet stehen bleiben. Maßgeblich sind dann Sitzungsfortschritt und
|
||||
|
||||
@@ -199,6 +199,13 @@ Open WebUI spricht nur mit dem Router auf dessen OpenAI-kompatibler `/v1`-API.
|
||||
Ein direkter Zugriff auf llama.cpp würde Profilumschaltung, Authentisierung,
|
||||
Vision-, Bild-, STT- und TTS-Routing umgehen.
|
||||
|
||||
Der Router ist außerdem die verbindliche Kompatibilitätsschicht für Thinking:
|
||||
Clients senden das OpenAI-/Hermes-Feld `reasoning_effort`; der Router überführt
|
||||
es in `chat_template_kwargs.reasoning_effort` beziehungsweise bei `none` in
|
||||
`enable_thinking: false`. Diese Übersetzung darf bei einem Router-Umbau nicht
|
||||
entfernt werden, weil llama.cpp das gleichnamige Top-Level-Feld nicht an das
|
||||
Qwen3.8-Chat-Template weiterreicht.
|
||||
|
||||
Sichtbare Arbeitsbereichsmodelle sind Fast, Medium, Large, Ultra und
|
||||
Uncensored. Die rohen `qwen-*`-Aliase bleiben ausgeblendet. Globale Filter
|
||||
behandeln Reasoning, Thinking, Kontext-/Toolschleifen, Secret-Redaktion,
|
||||
|
||||
@@ -1108,6 +1108,64 @@ def _request_has_image(data: dict) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# llama.cpp Chat-Template-Parameter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REASONING_EFFORT_MAP = {
|
||||
"minimal": "low",
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"high": "xhigh",
|
||||
"xhigh": "xhigh",
|
||||
"max": "xhigh",
|
||||
"ultra": "xhigh",
|
||||
}
|
||||
_REASONING_OFF = {"", "none", "off", "disabled", "false"}
|
||||
|
||||
|
||||
def _normalize_llamacpp_reasoning(data: dict) -> dict:
|
||||
"""Mappt OpenAI/Hermes-Reasoning auf llama.cpp-Template-Parameter.
|
||||
|
||||
Hermes sendet ``reasoning_effort`` bei einem Custom Provider als
|
||||
Top-Level-Feld. llama.cpp akzeptiert das Feld zwar, reicht es dort aber
|
||||
nicht an das Jinja-Chat-Template weiter. Qwen3.8 erwartet stattdessen
|
||||
``chat_template_kwargs.reasoning_effort`` bzw. ``enable_thinking=false``.
|
||||
|
||||
Die Funktion verändert den übergebenen Request absichtlich in-place und
|
||||
entfernt das wirkungslose Top-Level-Feld. Andere Template-Argumente des
|
||||
Clients bleiben erhalten.
|
||||
"""
|
||||
if "reasoning_effort" not in data:
|
||||
return data
|
||||
|
||||
raw_effort = data.pop("reasoning_effort")
|
||||
effort = str(raw_effort).strip().lower() if raw_effort is not None else ""
|
||||
template_kwargs = data.get("chat_template_kwargs")
|
||||
if not isinstance(template_kwargs, dict):
|
||||
template_kwargs = {}
|
||||
data["chat_template_kwargs"] = template_kwargs
|
||||
|
||||
if effort in _REASONING_OFF:
|
||||
template_kwargs.pop("reasoning_effort", None)
|
||||
template_kwargs["enable_thinking"] = False
|
||||
return data
|
||||
|
||||
mapped = _REASONING_EFFORT_MAP.get(effort)
|
||||
if mapped is None:
|
||||
# Unbekannte OpenAI-Erweiterungen dürfen das Qwen-Template nicht mit
|
||||
# einem ungültigen Wert zum Abbruch bringen. Das Template verwendet
|
||||
# in diesem Fall seine eigene Voreinstellung.
|
||||
log.warning("Unbekanntes reasoning_effort=%r ignoriert", raw_effort)
|
||||
if not template_kwargs:
|
||||
data.pop("chat_template_kwargs", None)
|
||||
return data
|
||||
|
||||
template_kwargs["enable_thinking"] = True
|
||||
template_kwargs["reasoning_effort"] = mapped
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP-Handler
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1916,6 +1974,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if profile is not None:
|
||||
switch_profile(profile, implicit=True)
|
||||
|
||||
data = _normalize_llamacpp_reasoning(data)
|
||||
|
||||
if _request_has_image(data):
|
||||
data = _normalize_chat_images(data)
|
||||
log.info("Vision: Bild wird direkt an das aktive "
|
||||
|
||||
Reference in New Issue
Block a user