A thinking block that says how long and how much
Each block reports its own round now. `reasoning_ms` was the reply's first burst, written once, so on the fifteen-block reply GPT-OSS actually produces only the first could claim a duration and the other fourteen said "Thought" and nothing at all. `Generation.thinking_ms` accumulates per round and `close_step` stamps it cumulatively, so steps.py diffs it exactly as it already diffs the three lengths beside it. The interval between a round's first and last reasoning delta, deliberately, not a sum of gaps between deltas -- that would count the network's latency as the model's thinking. While it runs: "Thinking" with an ellipsis that types itself, and the seconds and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is no timer to start, stop or clean up when the block is swapped away -- it stops existing when the element does. The numbers come from a `think` frame, and `round_thinking_ms` is written by the producer rather than computed by the follower from a start time: a model that has stopped thinking and moved on to a tool should show a settled number, not a clock that keeps running. Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by the live label and the stored one, so the two cannot drift into two conventions. The live duration is terser than the finished one -- `6s` against `6 seconds` -- because it sits beside an animating word and changes every second, where "less than a second" flickering into "1 second" reads as a glitch. Checked against the real endpoint: fourteen marks carrying 919ms through 14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k", and the live frames resetting each round rather than accumulating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,12 +24,18 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from lembas.services import tokens as tokens_service
|
||||
from lembas.services.markdown import open_fence, render_markdown
|
||||
from lembas.services.reasoning import format_duration
|
||||
|
||||
KIND_THINKING = "thinking"
|
||||
KIND_TEXT = "text"
|
||||
KIND_TOOLS = "tools"
|
||||
|
||||
# Below this a token count is printed exactly; above it, as `1.4k`. The point is
|
||||
# where the digits stop meaning anything to a reader.
|
||||
TOKENS_EXACT_BELOW = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Step:
|
||||
@@ -49,6 +55,56 @@ class Step:
|
||||
text: str = ""
|
||||
html: str = ""
|
||||
events: tuple[dict, ...] = field(default_factory=tuple)
|
||||
# How long this block thought for and roughly how much it produced. Only on
|
||||
# a thinking step, and only where there is something to say -- a block whose
|
||||
# duration was never recorded (every row written before the marks carried
|
||||
# one) shows the word alone rather than "Thought for 0 seconds".
|
||||
label: str = ""
|
||||
|
||||
|
||||
def format_tokens(count: int) -> str:
|
||||
"""A token count as a person reads it: `86`, or `0.2k` once it is worth it.
|
||||
|
||||
The threshold is where the exact number stops carrying information: nobody
|
||||
acts on the difference between 214 and 219 tokens of thinking, and four
|
||||
digits beside a spinner is noise. One helper, so the live label and the
|
||||
stored one cannot drift into two conventions.
|
||||
"""
|
||||
if count <= 0:
|
||||
return ""
|
||||
if count <= TOKENS_EXACT_BELOW:
|
||||
return str(count)
|
||||
return f"{count / 1000:.1f}k"
|
||||
|
||||
|
||||
def thinking_label(*, ms: int, tokens: int, live: bool) -> str:
|
||||
"""What a thinking block says about itself.
|
||||
|
||||
The two states are one function because they are one sentence with a
|
||||
different tense, and because the running one becomes the finished one in
|
||||
place -- a reader watching the numbers climb should see them settle, not be
|
||||
replaced by something formatted differently.
|
||||
|
||||
The word itself is not here: the live block animates its own ellipsis in CSS
|
||||
and the template owns that. This is only what follows it.
|
||||
"""
|
||||
parts = []
|
||||
if ms > 0:
|
||||
parts.append(format_duration(ms) if not live else _short_duration(ms))
|
||||
if counted := format_tokens(tokens):
|
||||
parts.append(counted)
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _short_duration(ms: int) -> str:
|
||||
"""`6s`, `1m 04s`. Terser than `format_duration` because it sits beside an
|
||||
animating word and changes every second; "less than a second" flickering
|
||||
into "1 second" reads as a glitch rather than as a measurement."""
|
||||
seconds = max(0, ms) // 1000
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
minutes, remainder = divmod(seconds, 60)
|
||||
return f"{minutes}m {remainder:02d}s"
|
||||
|
||||
|
||||
def for_message(message: Any) -> list[Step]:
|
||||
@@ -63,6 +119,7 @@ def for_message(message: Any) -> list[Step]:
|
||||
thinking=(message.reasoning or "") if not message.error else "",
|
||||
events=list(message.tool_calls_json or []),
|
||||
marks=list(getattr(message, "steps_json", None) or []),
|
||||
whole_ms=int(getattr(message, "reasoning_ms", 0) or 0),
|
||||
# A stored reply has a trailing step and it is not being written. Those
|
||||
# are two facts and they used to be one flag: `include_open` both
|
||||
# emitted the tail and marked it live, so every finished bubble ending
|
||||
@@ -118,6 +175,7 @@ def _build(
|
||||
since: int = 0,
|
||||
include_open: bool = True,
|
||||
live: bool = True,
|
||||
whole_ms: int = 0,
|
||||
) -> list[Step]:
|
||||
"""The shared walk.
|
||||
|
||||
@@ -141,7 +199,26 @@ def _build(
|
||||
# nothing: for that one the two orders are the same list, because there
|
||||
# are no tool blocks to sit between the prose.
|
||||
if thinking:
|
||||
steps.append(Step(index=0, kind=KIND_THINKING, text=thinking))
|
||||
# No marks means no per-round timing was ever recorded, so the
|
||||
# duration falls back to whatever the row knows about the reply as a
|
||||
# whole -- which for a single-round answer is exactly right, and is
|
||||
# what every row written before the marks existed carries.
|
||||
steps.append(
|
||||
Step(
|
||||
index=0,
|
||||
kind=KIND_THINKING,
|
||||
text=thinking,
|
||||
# Nothing baked in while the reply runs: the live block's
|
||||
# numbers come from the `think` frame, which knows the
|
||||
# clock. A label rendered here would be one that never
|
||||
# moved again.
|
||||
label=""
|
||||
if live
|
||||
else thinking_label(
|
||||
ms=whole_ms, tokens=tokens_service.estimate(thinking), live=False
|
||||
),
|
||||
)
|
||||
)
|
||||
if events:
|
||||
steps.append(Step(index=0, kind=KIND_TOOLS, events=tuple(events)))
|
||||
if text:
|
||||
@@ -153,6 +230,7 @@ def _build(
|
||||
thought_from = 0
|
||||
text_from = 0
|
||||
tools_from = 0
|
||||
thought_ms_from = 0
|
||||
carry = ""
|
||||
|
||||
for index, mark in enumerate(marks):
|
||||
@@ -175,7 +253,18 @@ def _build(
|
||||
if index >= since:
|
||||
# Thinking, then prose, then tools -- the order a model emits them.
|
||||
if thought:
|
||||
steps.append(Step(index=index, kind=KIND_THINKING, text=thought))
|
||||
steps.append(
|
||||
Step(
|
||||
index=index,
|
||||
kind=KIND_THINKING,
|
||||
text=thought,
|
||||
label=thinking_label(
|
||||
ms=_at(mark, "thinking_ms") - thought_ms_from,
|
||||
tokens=tokens_service.estimate(thought),
|
||||
live=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
if said:
|
||||
steps.append(Step(index=index, kind=KIND_TEXT, html=render_markdown(source)))
|
||||
if ran:
|
||||
@@ -183,6 +272,9 @@ def _build(
|
||||
|
||||
carry = f"{marker}{info}" if marker else ""
|
||||
thought_from, text_from, tools_from = thought_to, text_to, tools_to
|
||||
# Cumulative on the mark, so a block's own duration is the difference --
|
||||
# the same rule the three lengths above follow.
|
||||
thought_ms_from = max(_at(mark, "thinking_ms"), thought_ms_from)
|
||||
|
||||
if not include_open:
|
||||
return steps
|
||||
@@ -191,7 +283,24 @@ def _build(
|
||||
# live path and the stored one -- one rule instead of two that could drift.
|
||||
index = len(marks)
|
||||
if trailing_thought := thinking[thought_from:]:
|
||||
steps.append(Step(index=index, kind=KIND_THINKING, text=trailing_thought))
|
||||
# The last round closes no mark -- it is the round that stopped calling
|
||||
# tools -- so its duration is whatever the reply spent thinking beyond
|
||||
# the last one that did. Zero while the reply is running, where the live
|
||||
# block carries its own label instead.
|
||||
steps.append(
|
||||
Step(
|
||||
index=index,
|
||||
kind=KIND_THINKING,
|
||||
text=trailing_thought,
|
||||
label=""
|
||||
if live
|
||||
else thinking_label(
|
||||
ms=max(0, whole_ms - thought_ms_from),
|
||||
tokens=tokens_service.estimate(trailing_thought),
|
||||
live=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
if trailing_text := text[text_from:]:
|
||||
source = f"{carry}\n{trailing_text}" if carry else trailing_text
|
||||
steps.append(
|
||||
|
||||
Reference in New Issue
Block a user