"""Separating a reasoning model's thinking from its answer. Endpoints do this two different ways and LLeMbas has to cope with both: 1. A dedicated ``reasoning_content`` field in the streamed delta. This is what llama.cpp, llama-swap, vLLM and DeepSeek emit, and it is unambiguous. 2. ``...`` tags inline in ``content``. Ollama and various proxies do this, and it is a nuisance: the tags arrive split across chunks, so the text has to be scanned as a stream rather than with a regex at the end. The splitter below handles the second case. It buffers only as much as a partial tag could occupy, so latency is unaffected in the overwhelmingly common case where no tag is present at all. """ from __future__ import annotations from collections.abc import Iterator # Tag spellings seen in the wild. Checked longest-first so is not # mistaken for followed by "ing>". _TAGS: tuple[tuple[str, str], ...] = ( ("", ""), ("", ""), ("", ""), ) REASONING = "reasoning" CONTENT = "content" # Longest opening tag, minus one: the most that can ever need holding back # while waiting to see whether a partial " None: self._buffer = "" self._in_reasoning = False self._closing = "" def feed(self, chunk: str) -> Iterator[tuple[str, str]]: self._buffer += chunk yield from self._drain(final=False) def flush(self) -> Iterator[tuple[str, str]]: yield from self._drain(final=True) def _drain(self, *, final: bool) -> Iterator[tuple[str, str]]: while self._buffer: if self._in_reasoning: index = self._buffer.find(self._closing) if index == -1: # Hold back enough that a closing tag split across chunks is # still recognised once the rest arrives. keep = 0 if final else len(self._closing) - 1 emit, self._buffer = self._split(keep) if emit: yield (REASONING, emit) return if index: yield (REASONING, self._buffer[:index]) self._buffer = self._buffer[index + len(self._closing) :] self._in_reasoning = False self._closing = "" continue opening_at, opening, closing = self._find_opening() if opening_at == -1: keep = 0 if final else _MAX_PARTIAL emit, self._buffer = self._split(keep) if emit: yield (CONTENT, emit) return if opening_at: yield (CONTENT, self._buffer[:opening_at]) self._buffer = self._buffer[opening_at + len(opening) :] self._in_reasoning = True self._closing = closing def _find_opening(self) -> tuple[int, str, str]: best = (-1, "", "") for opening, closing in _TAGS: index = self._buffer.find(opening) if index != -1 and (best[0] == -1 or index < best[0]): best = (index, opening, closing) return best def _split(self, keep: int) -> tuple[str, str]: """Emit everything except the last `keep` characters.""" if keep <= 0: return self._buffer, "" if len(self._buffer) <= keep: return "", self._buffer return self._buffer[:-keep], self._buffer[-keep:] def strip_reasoning(text: str) -> tuple[str, str]: """Split a complete string into (answer, reasoning). The non-streaming counterpart, used when replaying stored content. """ splitter = ReasoningSplitter() answer: list[str] = [] thinking: list[str] = [] for kind, piece in splitter.feed(text): (thinking if kind == REASONING else answer).append(piece) for kind, piece in splitter.flush(): (thinking if kind == REASONING else answer).append(piece) return "".join(answer), "".join(thinking) def format_duration(milliseconds: int) -> str: """Human phrasing for the 'Thought for ...' label.""" if milliseconds <= 0: return "" seconds = milliseconds / 1000 if seconds < 1: return "less than a second" if seconds < 60: return f"{seconds:.0f} second{'' if round(seconds) == 1 else 's'}" minutes, remainder = divmod(int(seconds), 60) if remainder == 0: return f"{minutes} minute{'' if minutes == 1 else 's'}" return f"{minutes}m {remainder}s"