The streaming reasoning display read only reasoning_content/reasoning deltas; reasoning_details deltas were accumulated for replay continuity but their text was never routed to the live display, so models that stream thought solely via reasoning_details (xiaomi/mimo-v2.6-pro via OpenRouter) showed no thinking display at all. The non-streaming extract_reasoning already reads detail text — this closes the asymmetry. One representation per chunk is emitted: detail text when the details carry it, otherwise the plain reasoning field. Persisted/replayed fields are not rewritten; opaque replay material (encrypted/unknown types) stays unexposed. A callback that raises (or is None) no longer breaks the stream. Fixes #118851 Salvaged from #118888 by Wenfengcheng
76 lines
3.7 KiB
Python
76 lines
3.7 KiB
Python
"""Boundary repair for providers that stream reasoning as discrete summary parts.
|
|
|
|
Reasoning-summary models (OpenAI gpt-5.x and Responses-API relays onto the chat wire) emit one
|
|
``reasoning_content`` delta per *completed* summary part, each opening with a bold heading.
|
|
The chat wire lacks the Responses API's ``summary_index`` delimiter (verified live on Nous
|
|
Portal ``openai/gpt-5.6-sol``), so plain concatenation glues ``**One****Two**`` into one
|
|
half-bold paragraph. The boundary is re-derived from a delta opening a bold heading, matching
|
|
the blank-line join Hermes' own Responses adapter does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from agent.message_content import flatten_message_text
|
|
|
|
__all__ = ["append_streamed_reasoning_detail", "separate_glued_reasoning_blocks"]
|
|
|
|
|
|
def separate_glued_reasoning_blocks(previous: str, delta: Any) -> str:
|
|
"""Return *delta*, prefixed with a paragraph break when it glues onto *previous*.
|
|
|
|
A break is inserted when *delta* opens a *closed* bold heading and *previous* is mid-line
|
|
(heading butting heading, or prose butting heading). Token-streamed reasoning is left
|
|
alone: its deltas carry their own whitespace, and a fragment that merely opens emphasis
|
|
(``**`` alone) is not a part boundary — summary parts carry the whole heading in one delta.
|
|
"""
|
|
# Relays also emit content-part lists/dicts; fragments carry their own whitespace.
|
|
delta = flatten_message_text(delta, sep="")
|
|
glued = previous and delta and not previous[-1].isspace() and delta.startswith("**") and "**" in delta[2:]
|
|
return f"\n\n{delta}" if glued else delta
|
|
|
|
|
|
# reasoning_details entry types whose consecutive fragments are ONE logical block.
|
|
_MERGEABLE_DETAIL_TEXT_KEYS = {"reasoning.text": "text", "reasoning.summary": "summary"}
|
|
_BACKFILL_DETAIL_KEYS = ("signature", "id", "format", "index")
|
|
|
|
|
|
def streamed_reasoning_detail_text(detail: Any) -> str:
|
|
"""Readable text from a detail delta; never expose opaque replay material."""
|
|
dtype = detail.get("type") if isinstance(detail, dict) else getattr(detail, "type", None)
|
|
key = _MERGEABLE_DETAIL_TEXT_KEYS.get(dtype) if isinstance(dtype, str) else None
|
|
if key is None:
|
|
return ""
|
|
text = detail.get(key) if isinstance(detail, dict) else getattr(detail, key, None)
|
|
return text if isinstance(text, str) else ""
|
|
|
|
|
|
def append_streamed_reasoning_detail(details_acc: list, detail: Any) -> None:
|
|
"""Accumulate one streamed ``reasoning_details`` delta entry into *details_acc*.
|
|
|
|
OpenRouter streams ``reasoning_details`` as word-level deltas: consecutive
|
|
``reasoning.text`` / ``reasoning.summary`` entries are fragments of one logical
|
|
block and are merged (later fragments backfill ``signature``/``id`` the first
|
|
omitted); encrypted/opaque entries stay discrete. Unmerged, a long thought
|
|
replays as hundreds of one-word entries and providers that validate the
|
|
sequence shape on the next turn reject it. SDK objects are normalized to dicts.
|
|
"""
|
|
if not isinstance(detail, dict):
|
|
if hasattr(detail, "model_dump"):
|
|
detail = detail.model_dump(warnings=False)
|
|
elif hasattr(detail, "__dict__"):
|
|
detail = dict(detail.__dict__)
|
|
else:
|
|
return
|
|
dtype = detail.get("type")
|
|
merge_key = _MERGEABLE_DETAIL_TEXT_KEYS.get(dtype)
|
|
last = details_acc[-1] if details_acc else None
|
|
if last is not None and merge_key and last.get("type") == dtype and isinstance(detail.get(merge_key), str):
|
|
last[merge_key] = (last.get(merge_key) or "") + detail[merge_key]
|
|
for k in _BACKFILL_DETAIL_KEYS:
|
|
if last.get(k) in (None, "") and detail.get(k) not in (None, ""):
|
|
last[k] = detail[k]
|
|
return
|
|
details_acc.append(dict(detail))
|