From 63fc921988eab4865e85f66954427fd88c8e27c0 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:43 +0200 Subject: [PATCH] Map Hermes reasoning controls into Qwen templates --- dev/mock_upstream.py | 2 ++ dev/test_local.sh | 19 +++++++++++ dev/test_router_support.py | 50 +++++++++++++++++++++++++++++ docs/CURRENT_REFERENCE.md | 13 ++++++-- docs/QWEN_OPERATOR_CONTEXT.md | 7 ++++ router/ai_profile_router.py | 60 +++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 2 deletions(-) diff --git a/dev/mock_upstream.py b/dev/mock_upstream.py index 19e0eb9..2139e6f 100755 --- a/dev/mock_upstream.py +++ b/dev/mock_upstream.py @@ -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: diff --git a/dev/test_local.sh b/dev/test_local.sh index 526e5f7..77ff8dd 100755 --- a/dev/test_local.sh +++ b/dev/test_local.sh @@ -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" \ diff --git a/dev/test_router_support.py b/dev/test_router_support.py index ffb1aa5..55dd3b7 100644 --- a/dev/test_router_support.py +++ b/dev/test_router_support.py @@ -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: diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 2faa5a8..1ce6ee9 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -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 diff --git a/docs/QWEN_OPERATOR_CONTEXT.md b/docs/QWEN_OPERATOR_CONTEXT.md index 9fda516..f9c081a 100644 --- a/docs/QWEN_OPERATOR_CONTEXT.md +++ b/docs/QWEN_OPERATOR_CONTEXT.md @@ -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, diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 3718e31..1aa9d02 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -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 "