From 328e612eb7858ca6c41d4c575a5572d85fe625f6 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Thu, 9 Jul 2026 01:42:24 +0200 Subject: [PATCH] Fix empty thinking panels for vLLM builds using a bare "reasoning" key (v1.14.9) Confirmed by directly probing a streaming response from vllm-0.23.1rc1: its delta objects carry reasoning text under "reasoning", not the more common "reasoning_content" key every reasoning-display code path was checking for. Added a shared _reasoning_text() helper that checks both, used by the Conversation Playground, audiobook casting's live view, and character sheets. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- routes/conversation.py | 38 +++++++++++++++++++++++--------------- static/index.html | 6 +++--- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9acc82..fd6b058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.14.9] — 2026-07-09 + +### Fixed +- **"Thinking" panels stayed empty for models served by some vLLM builds** (confirmed on `vllm-0.23.1rc1`) — those backends stream reasoning under a plain `reasoning` key instead of the more common `reasoning_content` key. Every place that reads reasoning text (Conversation Playground, audiobook casting's live view, character sheets) now checks both, via a shared `_reasoning_text()` helper. Root-caused by directly probing the model's raw streaming response and finding `"delta":{"reasoning":"..."}` instead of the expected field name. + +--- + ## [1.14.8] — 2026-07-09 ### Changed diff --git a/VERSION b/VERSION index 9be7846..0b94c5f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.8 +1.14.9 diff --git a/routes/conversation.py b/routes/conversation.py index 137ea7b..65a93c5 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -114,6 +114,13 @@ def _sentence_split(buf: str) -> int: # ── LLM helpers ─────────────────────────────────────────────────────────────── +def _reasoning_text(obj: dict) -> str: + """Extract chain-of-thought text from a message or delta dict. Most + OpenAI-compatible backends use `reasoning_content`, but some vLLM builds + (observed: vllm-0.23.1rc1) use a plain `reasoning` key instead — check both.""" + return obj.get("reasoning_content") or obj.get("reasoning") or "" + + def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str: """Inline synchronous persona rewrite; raises RuntimeError on failure.""" settings = _load_settings() @@ -140,7 +147,7 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + result = (_msg.get("content") or _reasoning_text(_msg) or "").strip() if result.startswith('"') and result.endswith('"'): result = result[1:-1].strip() return result @@ -287,7 +294,7 @@ async def refine_text(request: Request): ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - refined = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + refined = (_msg.get("content") or _reasoning_text(_msg) or "").strip() if refined.startswith('"') and refined.endswith('"'): refined = refined[1:-1].strip() return {"text": refined, "original": text} @@ -348,7 +355,7 @@ async def rewrite_with_persona(request: Request): ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + result = (_msg.get("content") or _reasoning_text(_msg) or "").strip() if result.startswith('"') and result.endswith('"'): result = result[1:-1].strip() return {"text": result, "original": text, "persona": persona} @@ -431,7 +438,7 @@ async def analyze_characters(request: Request): ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip() content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw for candidate in (content, _extract_json_block(content)): if not candidate: @@ -529,7 +536,7 @@ async def match_characters_voices(request: Request): ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip() break except requests.exceptions.ConnectionError as e: # llama-swap (and similar) often drop the first request while swapping/loading @@ -795,7 +802,7 @@ async def character_sheets(request: Request): _attribution_llm_lock.release() resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip() except HTTPException: raise except Exception as e: @@ -873,7 +880,7 @@ async def character_sheets_stream(request: Request): delta = json.loads(chunk)["choices"][0]["delta"] except Exception: continue - t = delta.get("reasoning_content") or delta.get("content") or "" + t = _reasoning_text(delta) or delta.get("content") or "" if delta.get("content"): raw_parts.append(delta["content"]) if t: @@ -957,7 +964,7 @@ async def character_deep_analysis(request: Request): ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip() except Exception as e: raise HTTPException(502, f"LLM deep analysis failed: {e}") @@ -1090,7 +1097,7 @@ async def character_generate_prompts(request: Request): ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] - raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip() except Exception as e: print(f"[character-generate-prompts] LLM call failed for {fields}: {e}") return {} @@ -1310,7 +1317,7 @@ async def attribute_dialogue(request: Request): ) return _fallback_attribute_response(text) msg = resp.json()["choices"][0]["message"] - raw = (msg.get("content") or msg.get("reasoning_content") or "").strip() + raw = (msg.get("content") or _reasoning_text(msg) or "").strip() except HTTPException: raise except Exception as e: @@ -1443,10 +1450,11 @@ async def attribute_dialogue_stream(request: Request): delta = json.loads(chunk)["choices"][0]["delta"] except Exception: continue - # Reasoning models emit thoughts via reasoning_content; the - # answer JSON arrives via content. Forward both to the viewer, - # but only content counts toward the parseable answer. - t = delta.get("reasoning_content") or delta.get("content") or "" + # Reasoning models emit thoughts via reasoning_content (or, on + # some vLLM builds, a plain "reasoning" key — see _reasoning_text); + # the answer JSON arrives via content. Forward both to the + # viewer, but only content counts toward the parseable answer. + t = _reasoning_text(delta) or delta.get("content") or "" if delta.get("content"): raw_parts.append(delta["content"]) if t: @@ -2084,7 +2092,7 @@ async def conversation_turn( try: obj = json.loads(payload_str) d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {}) - reasoning = d_obj.get("reasoning_content") or "" + reasoning = _reasoning_text(d_obj) content = d_obj.get("content") or "" if not content and not reasoning and isinstance(obj.get("message"), dict): content = obj["message"].get("content") or "" diff --git a/static/index.html b/static/index.html index 906185e..26e74b2 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - +