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:
parent
8d5050bb80
commit
328e612eb7
@ -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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user