Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
77f1d8c
Merge origin/master into tps-per-session-indicator
May 1, 2026
1b6d96e
Merge origin/master into tps-per-session-indicator
Apr 30, 2026
1bf2e42
Fix test: right:6px is on .session-time-wrapper, not .session-attenti…
Apr 26, 2026
c666c58
fix sidebar: hide spinner/TPS on menu hover; skip refresh while chat …
Apr 26, 2026
12ba396
fix sidebar: also hide unread dot on hover/menu-open
Apr 26, 2026
7d2b7bc
fix test: update titlebar layout assertion for TPS space-between branch
Apr 29, 2026
33207ce
fix sidebar: hide spinner/TPS on row hover (not just trigger hover)
Apr 29, 2026
fc43577
cleanup: remove filter-branch backup artifacts
Apr 29, 2026
9e5ff7e
Fix: logo alt text, header title rename input styling, and preserve t…
Apr 26, 2026
336c59f
Fix: always drain queued message from background session on stream end
Apr 29, 2026
4d73f31
Fix: preserve draft on session switch, remove dblclick poll, auto-foc…
Apr 29, 2026
9db20cf
Revert broken draft save/delete in send() — drafts only via session s…
Apr 26, 2026
62475b1
fix: ignore right/middle mouse clicks on session items
Apr 29, 2026
ae73b6f
fix two regression bugs from rebase
Apr 29, 2026
c8530eb
fix tests: patch api.models.Session.load instead of get_session
Apr 29, 2026
730dfd0
fix(ui): add h4-h6 heading rendering and busy-input send action logic
Apr 29, 2026
e6d2a81
docs(metering): remove stale high/low references from docstring
Apr 30, 2026
6a1af9e
fix test: update titlebar layout assertion for center layout
May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 20 additions & 44 deletions api/metering.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""
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 all active WebUI sessions. Metering
data is emitted via SSE events so the sidebar indicator can update live
during a stream.

Architecture
────────────
Expand All @@ -15,13 +15,9 @@
This correctly represents the system's real-time capacity regardless of how
many sessions are running or how long each has been streaming.

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
so the ticker exits and no idle readings are emitted.
are actively receiving tokens so the sidebar updates at 1 Hz, and 10.0 when
idle so the ticker exits and no idle readings are emitted.

Usage from api/streaming.py
─────────────────────────────
Expand All @@ -33,10 +29,9 @@

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, # average TPS across active sessions (real-time)
"active": 1, # sessions currently streaming
"session_tps": ..., # per-session TPS when stream_id is passed to get_stats()
}
"""

Expand All @@ -45,8 +40,6 @@
import threading
import time
from dataclasses import dataclass

_HOUR_SECS = 3600.0 # rolling window for HIGH/LOW tracking
_STALE_SECS = 60.0 # consider a session inactive after this


Expand All @@ -69,22 +62,17 @@ def tps(self) -> float:
class GlobalMeter:
"""Thread-safe global streaming meter.

Tracks per-session TPS, averages them for a global tps, and maintains a
60-minute rolling history of global tps snapshots for HIGH/LOW reporting.
Tracks per-session TPS and computes an average for the global tps.
"""

__slots__ = (
'_lock',
'_sessions', # stream_id -> _SessionMeter
'_readings', # [(monotonic_ts, tps), ...] rolling 60-minute history
'_window_start', # monotonic ts of current window
)

def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, _SessionMeter] = {}
self._readings: list[tuple[float, float]] = []
self._window_start: float = time.monotonic()

# ── Public API ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -133,7 +121,7 @@ def end_session(self, stream_id: str, final_output_tokens: int, input_tokens: in
with self._lock:
self._sessions.pop(stream_id, None)

def get_stats(self) -> dict:
def get_stats(self, stream_id: str | None = None) -> dict:
now = time.monotonic()
with self._lock:
# Prune stale sessions
Expand All @@ -144,39 +132,27 @@ def get_stats(self) -> dict:
for sid in stale:
self._sessions.pop(sid, None)

# Reset window if everything went stale
if not self._sessions:
self._window_start = now

# Compute global tps: average of per-session TPS 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)
else:
global_tps = 0.0

# Prune readings older than 1 hour
cutoff = now - _HOUR_SECS
self._readings = [(ts, v) for ts, v in self._readings if ts > cutoff]

# 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:
self._readings.append((now, global_tps))

# HIGH/LOW from the past hour (skip near-zero idle readings)
active_readings = [v for _, v in self._readings if v >= 1.0]
high = max(active_readings) if active_readings else 0.0
low = min(active_readings) if active_readings else 0.0

return {
result = {
'tps': round(global_tps, 1),
'high': round(high, 1),
'low': round(low, 1),
'active': len(self._sessions),
}

# When called with a stream_id, include that session's own TPS so the
# SSE metering event can update the correct per-conversation indicator.
if stream_id is not None:
s = self._sessions.get(stream_id)
if s is not None and s.first_token_ts > 0:
result['session_tps'] = round(s.tps(), 1)

return result


# ── Module-level singleton ─────────────────────────────────────────────────────

Expand Down
11 changes: 6 additions & 5 deletions api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -1556,8 +1556,9 @@ def _emit_metering():
if now - _metering_last_emit[0] < 0.1:
return
_metering_last_emit[0] = now
stats = meter().get_stats()
stats['session_id'] = stream_id
stats = meter().get_stats(stream_id)
stats['session_id'] = session_id
stats['stream_id'] = stream_id
put('metering', stats)

def on_token(text):
Expand All @@ -1571,7 +1572,7 @@ def on_token(text):
put('token', {'text': text})
# Update global throughput meter
meter().record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))
_emit_metering()
_emit_metering() # must follow record_token so first_token_ts is set

def on_reasoning(text):
nonlocal _reasoning_text
Expand All @@ -1584,7 +1585,7 @@ def on_reasoning(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))
_emit_metering()
_emit_metering() # must follow record_reasoning so first_token_ts is set

# Pre-initialise the activity counter here so on_tool (which
# closes over it) never captures an unbound name even if this
Expand Down Expand Up @@ -1618,7 +1619,7 @@ def on_tool(*cb_args, **cb_kwargs):
STREAM_REASONING_TEXT[stream_id] += str(reason_text)
put('reasoning', {'text': str(reason_text)})
meter().record_reasoning(stream_id, len(_reasoning_text))
_emit_metering()
_emit_metering() # must follow record_reasoning so first_token_ts is set
return

args_snap = {}
Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="app-titlebar-inner">
<span class="app-titlebar-icon" aria-hidden="true">
<span class="app-titlebar-icon" role="img" aria-label="Hermes">
<svg viewBox="0 0 64 64" width="16" height="16" aria-hidden="true">
<defs>
<linearGradient id="app-titlebar-gold" x1="0%" y1="0%" x2="0%" y2="100%">
Expand Down
28 changes: 19 additions & 9 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ function closeLiveStream(sessionId, streamId){
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!activeSid||!streamId) return;
const reconnecting=!!options.reconnecting;
// _meteringSid: the session_id that the sidebar items use as data-session-id.
// The metering SSE event carries stream_id as its 'session_id' field,
// so we need this local copy to route t/s updates to the right sidebar row.
const _meteringSid = activeSid;
closeLiveStream(activeSid);
if(!INFLIGHT[activeSid]) INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[...uploaded],toolCalls:[]};
else {
Expand Down Expand Up @@ -882,6 +886,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if((d.session_id||activeSid)!==activeSid) return;
}catch(_){}
source.close();
// Mirror the drain that done{} performs — stream_end can fire on its
// own without a prior done event (e.g. server-sent close, network drop).
_queueDrainSid=activeSid;setBusy(false);
});

source.addEventListener('pending_steer_leftover',e=>{
Expand Down Expand Up @@ -929,17 +936,20 @@ 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).
// Rolling 5-second average tps per session — maintained by _updateSessionTpsLabel.
// d.session_tps is the per-session TPS (from meter().get_stats(stream_id));
// d.tps is the global average across all active sessions.
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:''}`;
}catch(_){}
if(!d.session_id){ return; }
// Use per-session TPS; fall back to global tps if not present.
const rawTps = (typeof d.session_tps === 'number' && d.session_tps > 0)
? d.session_tps
: (typeof d.tps === 'number' ? d.tps : null);
// Use d.session_id directly — the backend now sends the actual session_id,
// not stream_id (they differ for resumed/continued sessions).
_updateSessionTpsLabel(d.session_id, rawTps);
}catch(err){}
});

source.addEventListener('apperror',e=>{
Expand Down
Loading
Loading