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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-09 01:42:24 +02:00
parent 8d5050bb80
commit 328e612eb7
4 changed files with 34 additions and 19 deletions

View File

@ -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

View File

@ -1 +1 @@
1.14.8
1.14.9

View File

@ -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"<think>.*?</think>", "", 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 ""

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.14.8">
<meta name="app-version" content="1.14.9">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.14.8">
<link rel="stylesheet" href="/static/style.css?v=1.14.9">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.14.8"></script>
<script src="/static/loader.js?v=1.14.9"></script>
</body>
</html>