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) {
-
+