fix: show TPS in assistant message headers

This commit is contained in:
Michael Lam
2026-05-04 10:55:23 -07:00
committed by test
parent 4085a1ff4d
commit 3ad8846a27
9 changed files with 171 additions and 47 deletions

View File

@@ -1,17 +1,17 @@
"""
Hermes Web UI -- Streaming performance metering.
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
emitted via SSE events so the header label can update live during a stream.
Tracks Tokens Per Second (TPS) across active WebUI streams. Metering data is
emitted via SSE events so a streaming assistant message can update its own
header while the turn is running.
Architecture
────────────
Each streaming session is tracked independently. TPS per session is:
Each streaming session is tracked independently. TPS per stream is:
session_tps = total_tokens / (last_token_ts - first_token_ts)
stream_tps = total_stream_deltas / (last_delta_ts - first_delta_ts)
The global tps is the average of all currently active sessions' TPS values.
The global tps is the average of all currently active streams' TPS values.
This correctly represents the system's real-time capacity regardless of how
many sessions are running or how long each has been streaming.
@@ -19,8 +19,8 @@ For HIGH/LOW tracking, every stats snapshot records the current global tps
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
The max/min of that history gives the peak throughput observed over the past hour.
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
The ticker in streaming.py calls get_interval() — it returns 1.0 when streams
are actively receiving output deltas so message headers update at 1 Hz, and 10.0 when idle
so the ticker exits and no idle readings are emitted.
Usage from api/streaming.py
@@ -28,15 +28,17 @@ Usage from api/streaming.py
from api.metering import meter
meter().begin_session(stream_id) # stream starts
meter().record_token(stream_id, running_output) # per output token
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
meter().record_token(stream_id, running_output_deltas)
meter().record_reasoning(stream_id, running_reasoning_deltas)
The SSE `metering` event payload:
{
"tps": 47.3, # average TPS across active sessions (real-time)
"high": 52.1, # highest average TPS observed in the past 60 minutes
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
"active": 1, # sessions currently streaming
"tps": 47.3, # omitted/null until a real reading exists
"tps_available": true, # frontend must hide TPS when false
"estimated": false, # never show byte/character-size estimates
"high": 52.1,
"low": 31.4,
"active": 1,
}
"""
@@ -60,9 +62,9 @@ class _SessionMeter:
def total_tokens(self) -> int:
return self.output_tokens + self.reasoning_tokens
def tps(self) -> float:
def tps(self) -> float | None:
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
return 0.0
return None
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
@@ -148,12 +150,15 @@ class GlobalMeter:
if not self._sessions:
self._window_start = now
# Compute global tps: average of per-session TPS values
# Compute global tps: average only streams with a real reading. The
# UI hides TPS entirely when this is unavailable instead of showing
# placeholder/estimated values.
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
if active:
global_tps = sum(s.tps() for s in active) / len(active)
active_tps = [v for s in active for v in [s.tps()] if v is not None and v > 0]
if active_tps:
global_tps = sum(active_tps) / len(active_tps)
else:
global_tps = 0.0
global_tps = None
# Prune readings older than 1 hour
cutoff = now - _HOUR_SECS
@@ -162,7 +167,7 @@ class GlobalMeter:
# Only record this snapshot for HIGH/LOW if there is active work.
# This prevents idle periods from flooding the history and keeps
# HIGH/LOW meaningful for the past hour of actual throughput.
if global_tps > 0:
if global_tps is not None and global_tps > 0:
self._readings.append((now, global_tps))
# HIGH/LOW from the past hour (skip near-zero idle readings)
@@ -171,9 +176,11 @@ class GlobalMeter:
low = min(active_readings) if active_readings else 0.0
return {
'tps': round(global_tps, 1),
'high': round(high, 1),
'low': round(low, 1),
'tps': round(global_tps, 1) if global_tps is not None else None,
'tps_available': global_tps is not None,
'estimated': False,
'high': round(high, 1) if high else None,
'low': round(low, 1) if low else None,
'active': len(self._sessions),
}

View File

@@ -1620,9 +1620,11 @@ def _run_agent_streaming(
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
# Throttle: emit metering events at most every 100 ms so the TPS label
# feels live during fast token streams without flooding the SSE channel.
# Throttle: emit metering events at most every 100 ms so the per-message
# TPS label feels live during fast token streams without flooding SSE.
_metering_last_emit = [time.monotonic() - 1] # fire immediately on first token
_metering_output_deltas = [0]
_metering_reasoning_deltas = [0]
def _emit_metering():
now = time.monotonic()
@@ -1631,6 +1633,8 @@ def _run_agent_streaming(
_metering_last_emit[0] = now
stats = meter().get_stats()
stats['session_id'] = stream_id
stats.setdefault('tps_available', False)
stats.setdefault('estimated', False)
put('metering', stats)
def on_token(text):
@@ -1642,8 +1646,11 @@ def _run_agent_streaming(
if stream_id in STREAM_PARTIAL_TEXT:
STREAM_PARTIAL_TEXT[stream_id] += str(text)
put('token', {'text': text})
# Update global throughput meter
meter().record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))
# Update live throughput from stream delta callbacks, not from
# byte/character length. If a backend cannot provide live deltas,
# the frontend hides TPS rather than showing an estimate.
_metering_output_deltas[0] += 1
meter().record_token(stream_id, _metering_output_deltas[0])
_emit_metering()
def on_reasoning(text):
@@ -1655,8 +1662,9 @@ def _run_agent_streaming(
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += str(text)
put('reasoning', {'text': str(text)})
# Track reasoning tokens in the meter so TPS reflects all AI output
meter().record_reasoning(stream_id, len(_reasoning_text))
# Track reasoning deltas in the meter so live TPS reflects all AI output.
_metering_reasoning_deltas[0] += 1
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
_emit_metering()
# Pre-initialise the activity counter here so on_tool (which
@@ -1690,7 +1698,8 @@ def _run_agent_streaming(
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += str(reason_text)
put('reasoning', {'text': str(reason_text)})
meter().record_reasoning(stream_id, len(_reasoning_text))
_metering_reasoning_deltas[0] += 1
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
_emit_metering()
return
@@ -2459,10 +2468,15 @@ def _run_agent_streaming(
_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))
except Exception:
_turn_duration_seconds = 0.0
_turn_tps = None
if output_tokens and _turn_duration_seconds > 0:
_turn_tps = round(float(output_tokens) / _turn_duration_seconds, 1)
if s.messages:
for _dm in reversed(s.messages):
if isinstance(_dm, dict) and _dm.get('role') == 'assistant':
_dm['_turnDuration'] = round(_turn_duration_seconds, 3)
if _turn_tps is not None:
_dm['_turnTps'] = _turn_tps
break
# Persist context window data on the session so the context-ring
# indicator survives a page reload (#1318). Must run BEFORE
@@ -2517,6 +2531,8 @@ def _run_agent_streaming(
'estimated_cost': estimated_cost,
'duration_seconds': round(_turn_duration_seconds, 3),
}
if _turn_tps is not None:
usage['tps'] = _turn_tps
# Include context window data from the agent's compressor for the UI indicator.
# The session-level persistence happens above (before s.save()) so the values
# survive a page reload; this block only populates the live SSE usage payload.
@@ -2567,9 +2583,11 @@ def _run_agent_streaming(
logger.debug("Failed to drain pending steer for session %s", session_id)
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
# Emit metering stats for the header TPS label
# Emit one last metering packet for the live message-header TPS label.
meter_stats = meter().get_stats()
meter_stats['session_id'] = session_id
meter_stats.setdefault('tps_available', False)
meter_stats.setdefault('estimated', False)
put('metering', meter_stats)
if _should_bg_title and _u0 and _a0:
threading.Thread(

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -903,6 +903,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof d.usage.duration_seconds==='number'){
lastAsst._turnDuration=d.usage.duration_seconds;
}
if(typeof d.usage.tps==='number'&&d.usage.tps>0){
lastAsst._turnTps=d.usage.tps;
}
}
}
if(d.session.tool_calls&&d.session.tool_calls.length){
@@ -984,16 +987,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('metering',e=>{
// TPS + HIGH/LOW stats for the header chip — emitted at 1 Hz during a stream,
// silenced entirely when no sessions are active (ticker exits when idle).
try{
const d=JSON.parse(e.data||'{}');
const el=$('tpsStat');
if(!el) return;
const tps=typeof d.tps==='number'?d.tps.toFixed(1):'0.0';
const high=typeof d.high==='number' && d.high>=0?d.high.toFixed(1)+' high':'—';
const low=typeof d.low==='number' && d.low>=0?d.low.toFixed(1)+' low':'';
el.textContent=`${tps} t/s · ${high}${low?' · '+low:''}`;
if((d.session_id||activeSid)!==activeSid) return;
if(d.estimated===true||d.tps_available!==true||typeof d.tps!=='number'||d.tps<=0){
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
return;
}
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(d.tps);
}catch(_){}
});

View File

@@ -2665,6 +2665,20 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
.msg-role { font-size: 11px; font-weight: 500; margin-bottom: 6px; opacity: .8; letter-spacing: 0; }
.msg-role:hover { opacity: 1; }
.role-icon { width: 20px; height: 20px; font-size: 9px; }
.msg-tps-inline {
display: inline-flex;
align-items: center;
margin-left: 2px;
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
background: var(--surface);
font-size: 10.5px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.4;
}
.msg-time { opacity: .65; font-size: 10px; }
.msg-role:hover .msg-time { opacity: 1; }

View File

@@ -3179,15 +3179,40 @@ function _messageHasReasoningPayload(m){
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
return /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>/.test(String(m.content||''));
}
function _assistantRoleHtml(tsTitle=''){
const _bn=window._botName||'Hermes';
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span></div>`;
function _formatTurnTps(value){
const n=Number(value);
if(!Number.isFinite(n)||n<=0) return '';
const fixed=n>=100?Math.round(n).toLocaleString():n>=10?n.toFixed(1):n.toFixed(1);
return `${fixed} t/s`;
}
function _createAssistantTurn(tsTitle=''){
function _assistantRoleHtml(tsTitle='', tpsText=''){
const _bn=window._botName||'Hermes';
const tps=tpsText?`<span class="msg-tps-inline" title="Tokens per second">${esc(tpsText)}</span>`:'';
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span>${tps}</div>`;
}
function _setAssistantTurnTps(turn, tpsText=''){
if(!turn) return;
const role=turn.querySelector('.msg-role.assistant');
if(!role) return;
let chip=role.querySelector('.msg-tps-inline');
const text=String(tpsText||'').trim();
if(!text){if(chip) chip.remove();return;}
if(!chip){
chip=document.createElement('span');
chip.className='msg-tps-inline';
chip.title='Tokens per second';
role.appendChild(chip);
}
chip.textContent=text;
}
function _setLiveAssistantTps(value){
_setAssistantTurnTps($('liveAssistantTurn'), _formatTurnTps(value));
}
function _createAssistantTurn(tsTitle='', tpsText=''){
const row=document.createElement('div');
row.className='msg-row assistant-turn';
row.dataset.role='assistant';
row.innerHTML=`${_assistantRoleHtml(tsTitle)}<div class="assistant-turn-blocks"></div>`;
row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}<div class="assistant-turn-blocks"></div>`;
return row;
}
function _assistantTurnBlocks(turn){
@@ -3837,7 +3862,7 @@ function renderMessages(){
}
if(!currentAssistantTurn){
currentAssistantTurn=_createAssistantTurn(tsTitle);
currentAssistantTurn=_createAssistantTurn(tsTitle, _formatTurnTps(m._turnTps));
inner.appendChild(currentAssistantTurn);
}
const seg=document.createElement('div');

View File

@@ -0,0 +1,59 @@
"""Regression coverage for issue #1617: TPS belongs on message headers.
Product decision:
- show live TPS in the assistant message header while streaming when real TPS is available;
- persist/show the final TPS at the end of the turn;
- do not show placeholder or estimated TPS when unavailable.
"""
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def test_tps_renders_in_message_header_not_global_titlebar():
assert "msg-tps-inline" in UI_JS, "assistant message headers need a TPS chip hook"
assert "msg-tps-inline" in CSS, "TPS header chip needs an explicit CSS hook"
assert "_assistantRoleHtml(tsTitle='', tpsText='')" in UI_JS, (
"assistant role/header rendering should accept the per-message TPS text"
)
assert "_formatTurnTps" in UI_JS, "TPS formatting should be centralized"
assert "_turnTps" in UI_JS, "settled assistant messages should render final TPS from message metadata"
assert "tpsStat" not in MESSAGES_JS, "live TPS must not target the removed/global titlebar chip"
def test_live_metering_updates_only_real_tps_and_never_placeholders():
listener_start = MESSAGES_JS.find("source.addEventListener('metering'")
assert listener_start != -1, "messages.js should listen for metering SSE events"
listener_end = MESSAGES_JS.find("source.addEventListener('apperror'", listener_start)
assert listener_end != -1, "apperror listener should follow metering listener"
listener = MESSAGES_JS[listener_start:listener_end]
assert "_setLiveAssistantTps" in listener, "live metering should update the live assistant header"
assert "tps_available" in listener and "estimated" in listener, (
"live TPS display must check availability and reject estimated readings"
)
assert "0.0 t/s" not in listener, "unavailable TPS should render nothing, not a 0.0 placeholder"
assert "''" not in listener and '""' not in listener, "unavailable TPS should render nothing, not a dash"
assert "high" not in listener.lower() and "low" not in listener.lower(), (
"message-header TPS should not carry global HIGH/LOW titlebar semantics"
)
def test_done_payload_persists_final_tps_when_exact_usage_available():
assert "usage['tps']" in STREAMING_PY, "done usage payload should include final exact TPS when available"
assert "output_tokens" in STREAMING_PY and "duration_seconds" in STREAMING_PY, (
"final TPS should be based on exact completion tokens over measured turn duration"
)
assert "d.usage.tps" in MESSAGES_JS, "done handler should read final TPS from the usage payload"
assert "lastAsst._turnTps" in MESSAGES_JS, "done handler should persist final TPS on the last assistant message"
def test_backend_marks_streaming_metering_availability_explicitly():
assert "tps_available" in STREAMING_PY, "metering SSE payloads must explicitly say whether TPS is displayable"
assert "estimated" in STREAMING_PY, "metering SSE payloads must explicitly distinguish estimated readings"
assert "record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))" not in STREAMING_PY, (
"live TPS must not be derived from streamed character count / byte-size estimates"
)

View File

@@ -38,7 +38,7 @@ def test_streaming_persists_context_fields_on_session_before_save():
# Save call follows shortly after
save_call = src.find("\n s.save()", block_start)
assert save_call != -1, "s.save() not found after the post-merge marker"
assert save_call - block_start < 3000, (
assert save_call - block_start < 3400, (
"s.save() should be close to the post-merge marker — block expanded unexpectedly. "
"If you've added a new pre-save mutation block here, bump this limit."
)