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:
Jaroslav Beneš
2026-08-04 21:03:25 +02:00
parent e107b5069d
commit fe43e95b79
11 changed files with 427 additions and 12 deletions
+58 -1
View File
@@ -103,6 +103,17 @@ class Generation:
content: list[str] = field(default_factory=list)
reasoning: list[str] = field(default_factory=list)
reasoning_ms: int = 0
# Milliseconds spent thinking, summed over rounds. Distinct from
# `reasoning_ms`, which is the reply's *first* burst and is written once --
# right for "Thought for 8 seconds" on a single-round answer, and unable to
# say anything about round seven of forty. Stamped on each mark by
# `close_step` and diffed by services/steps.py into a per-block figure.
thinking_ms: int = 0
# How long the round *currently* running has been thinking. Read by the live
# block's label, and written by the producer rather than computed from a
# start time by the follower: a model that has stopped thinking and moved on
# to a tool should show a settled number, not a clock that keeps running.
round_thinking_ms: int = 0
# One entry per tool call made while producing this reply, in order. Shown
# live as the model works and kept on the message afterwards.
@@ -234,6 +245,8 @@ class Generation:
"thinking_to": len(self.thinking),
"text_to": len(self.text),
"tools_to": len(self.tool_events),
# Cumulative, like the three above it, and diffed the same way.
"thinking_ms": self.thinking_ms,
}
)
@@ -554,6 +567,13 @@ async def _run(generation: Generation) -> None:
# Text the model produced in *this* round, needed separately from
# generation.content when echoing the assistant turn back.
round_text: list[str] = []
# When this round's thinking started and when it was last seen, so
# the interval can be added to `generation.thinking_ms` at the
# round's end. Per round, because the thinking block is per round:
# `reasoning_ms` is the whole reply's first burst, written once, and
# cannot say how long round seven thought for. `None` until the
# round thinks at all -- plenty of rounds do not.
round_thinking: tuple[float, float] | None = None
async for chunk in stream_chat(endpoint, payload):
counts = chunk_usage(chunk)
@@ -572,6 +592,7 @@ async def _run(generation: Generation) -> None:
if thought:
if reasoning_started is None:
reasoning_started = time.monotonic()
round_thinking = _thought_at(generation, round_thinking)
generation.reasoning.append(thought)
generation.touch()
@@ -586,6 +607,7 @@ async def _run(generation: Generation) -> None:
if kind == REASONING:
if reasoning_started is None:
reasoning_started = time.monotonic()
round_thinking = _thought_at(generation, round_thinking)
generation.reasoning.append(piece)
else:
if reasoning_started is not None and not generation.reasoning_ms:
@@ -624,6 +646,8 @@ async def _run(generation: Generation) -> None:
# output, so the mark belongs at the round's end.
# See metrics._since_counted.
_mark_counted(generation)
# Before `close_step` below, which stamps the total this adds to.
round_thinking = _close_thinking(generation, round_thinking)
calls = accumulator.calls
if generation.stopped or not calls:
@@ -1218,6 +1242,35 @@ def _too_big(generation: Generation) -> bool:
return generation.prompt_estimate > generation.context_limit * CONTEXT_HEADROOM
def _thought_at(generation: Generation, span: tuple[float, float] | None) -> tuple[float, float]:
"""Widen this round's thinking interval to now.
First call in a round opens it; every later one moves its end. The interval
rather than a running sum, because reasoning arrives in a burst of small
deltas and adding a gap per delta would count the network's latency as the
model's thinking.
"""
now = time.monotonic()
span = (now, now) if span is None else (span[0], now)
generation.round_thinking_ms = int((span[1] - span[0]) * 1000)
return span
def _close_thinking(
generation: Generation, span: tuple[float, float] | None
) -> tuple[float, float] | None:
"""Add this round's thinking to the reply's total. Returns None to reopen.
Called where the round ends, so `close_step` can stamp a cumulative figure
that `services/steps.py` diffs into a per-block duration -- the same shape
as the three lengths it already stamps.
"""
if span is not None:
generation.thinking_ms += int((span[1] - span[0]) * 1000)
generation.round_thinking_ms = 0
return None
def _mark_counted(generation: Generation) -> None:
"""Record that everything written so far is covered by a reported count.
@@ -1848,7 +1901,11 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
message.content = generation.text
message.reasoning = generation.thinking
message.reasoning_ms = generation.reasoning_ms
# `thinking_ms` in preference: it is the same measurement done
# properly, summed over every round rather than stopping at the
# first burst, and it is what the trailing block's duration is
# derived from. Falls back for a reply that produced no marks.
message.reasoning_ms = generation.thinking_ms or generation.reasoning_ms
message.tool_calls_json = generation.tool_events
# Written together with the three stores it indexes, by the one
# writer, so a row can never carry marks that describe a different