Stage 374: PR #2421 — fix(cache-tokens): surface provider prompt-cache read/write tokens in WebUI usage by @Michaelyklam (fixes #2419)

Co-authored-by: Michael Lam <michael@example.local>
This commit is contained in:
nesquena-hermes
2026-05-17 02:49:34 +00:00
parent b3bf2347e4
commit 47c210899e
6 changed files with 106 additions and 3 deletions

View File

@@ -365,6 +365,7 @@ class Session:
tool_calls=None, pinned: bool=False, archived: bool=False,
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
cache_read_tokens: int=0, cache_write_tokens: int=0,
personality=None,
active_stream_id: str=None,
pending_user_message: str=None,
@@ -403,6 +404,8 @@ class Session:
self.input_tokens = input_tokens or 0
self.output_tokens = output_tokens or 0
self.estimated_cost = estimated_cost
self.cache_read_tokens = cache_read_tokens or 0
self.cache_write_tokens = cache_write_tokens or 0
self.personality = personality
self.active_stream_id = active_stream_id
self.pending_user_message = pending_user_message
@@ -465,6 +468,7 @@ class Session:
'session_id', 'title', 'workspace', 'model', 'model_provider', 'created_at', 'updated_at',
'pinned', 'archived', 'project_id', 'profile',
'input_tokens', 'output_tokens', 'estimated_cost',
'cache_read_tokens', 'cache_write_tokens',
'personality', 'active_stream_id',
'pending_user_message', 'pending_attachments', 'pending_started_at',
'compression_anchor_visible_idx', 'compression_anchor_message_key',
@@ -628,6 +632,8 @@ class Session:
'input_tokens': self.input_tokens,
'output_tokens': self.output_tokens,
'estimated_cost': self.estimated_cost,
'cache_read_tokens': self.cache_read_tokens,
'cache_write_tokens': self.cache_write_tokens,
'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key,

View File

@@ -2827,6 +2827,8 @@ def _run_agent_streaming(
'input_tokens': 0,
'output_tokens': 0,
'estimated_cost': 0,
'cache_read_tokens': 0,
'cache_write_tokens': 0,
'context_length': 0,
'threshold_tokens': 0,
'last_prompt_tokens': 0,
@@ -2842,6 +2844,8 @@ def _run_agent_streaming(
_usage['input_tokens'] = getattr(_agent, 'session_prompt_tokens', 0) or 0
_usage['output_tokens'] = getattr(_agent, 'session_completion_tokens', 0) or 0
_usage['estimated_cost'] = getattr(_agent, 'session_estimated_cost_usd', 0) or 0
_usage['cache_read_tokens'] = getattr(_agent, 'session_cache_read_tokens', 0) or 0
_usage['cache_write_tokens'] = getattr(_agent, 'session_cache_write_tokens', 0) or 0
except Exception:
pass
try:
@@ -2854,7 +2858,7 @@ def _run_agent_streaming(
pass
if _session_obj is not None:
for _field in ('input_tokens', 'output_tokens', 'estimated_cost', 'context_length', 'threshold_tokens', 'last_prompt_tokens'):
for _field in ('input_tokens', 'output_tokens', 'estimated_cost', 'cache_read_tokens', 'cache_write_tokens', 'context_length', 'threshold_tokens', 'last_prompt_tokens'):
if not _usage.get(_field):
try:
_usage[_field] = getattr(_session_obj, _field, 0) or 0
@@ -4260,12 +4264,18 @@ def _run_agent_streaming(
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0
estimated_cost = getattr(agent, 'session_estimated_cost_usd', None)
cache_read_tokens = getattr(agent, 'session_cache_read_tokens', 0) or 0
cache_write_tokens = getattr(agent, 'session_cache_write_tokens', 0) or 0
if input_tokens > 0:
s.input_tokens = input_tokens
if output_tokens > 0:
s.output_tokens = output_tokens
if estimated_cost is not None:
s.estimated_cost = estimated_cost
if cache_read_tokens > 0:
s.cache_read_tokens = cache_read_tokens
if cache_write_tokens > 0:
s.cache_write_tokens = cache_write_tokens
# Persist tool-call summaries even when the final message history only
# kept bare tool rows and omitted explicit assistant tool_call IDs.
tool_calls = _extract_tool_calls_from_messages(
@@ -4493,6 +4503,8 @@ def _run_agent_streaming(
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'estimated_cost': estimated_cost,
'cache_read_tokens': cache_read_tokens,
'cache_write_tokens': cache_write_tokens,
'duration_seconds': round(_turn_duration_seconds, 3),
}
if _turn_tps is not None:

View File

@@ -1480,6 +1480,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _prevIn=(S.session&&S.session.input_tokens)||0;
const _prevOut=(S.session&&S.session.output_tokens)||0;
const _prevCost=(S.session&&S.session.estimated_cost)||0;
const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0;
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
@@ -1509,12 +1511,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const curIn=d.usage.input_tokens||0;
const curOut=d.usage.output_tokens||0;
const curCost=d.usage.estimated_cost||0;
const curCacheRead=d.usage.cache_read_tokens||0;
const curCacheWrite=d.usage.cache_write_tokens||0;
// Only set delta if values actually increased (skip no-op turns)
if(curIn>prevIn||curOut>prevOut){
if(curIn>prevIn||curOut>prevOut||curCacheRead>_prevCacheRead||curCacheWrite>_prevCacheWrite){
lastAsst._turnUsage={
input_tokens:Math.max(0,curIn-prevIn),
output_tokens:Math.max(0,curOut-prevOut),
estimated_cost:Math.max(0,curCost-prevCost),
cache_read_tokens:Math.max(0,curCacheRead-_prevCacheRead),
cache_write_tokens:Math.max(0,curCacheWrite-_prevCacheWrite),
};
}
if(typeof d.usage.duration_seconds==='number'){

View File

@@ -2120,12 +2120,14 @@ function _syncCtxIndicator(usage){
// branch below — honest "no data" instead of misleading "890% used".
const promptTok=usage.last_prompt_tokens||0;
const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0);
const cacheReadTok=usage.cache_read_tokens||0;
const cacheWriteTok=usage.cache_write_tokens||0;
// Default context window to 128K when not provided by backend
const DEFAULT_CTX=128*1024;
const ctxWindow=usage.context_length||DEFAULT_CTX;
const cost=usage.estimated_cost;
// Show indicator whenever we have any usage data (tokens or cost)
if(!promptTok&&!totalTok&&!cost){
if(!promptTok&&!totalTok&&!cost&&!cacheReadTok&&!cacheWriteTok){
if(wrap) wrap.style.display='none';
_syncMobileCtxDisplay({visible:false});
return;
@@ -2158,9 +2160,13 @@ function _syncCtxIndicator(usage){
const compressText=pct>=75?t('ctx_compress_action'):(pct>=50?t('ctx_compress_hint'):'');
if(compressWrap) compressWrap.style.display=compressText?'':'none';
_setCtxCompressButton(compressBtn,compressText);
const cacheTotalTok=cacheReadTok+cacheWriteTok;
const cacheHitPct=cacheTotalTok?Math.round((cacheReadTok/cacheTotalTok)*100):null;
const cacheText=cacheTotalTok?`cache: ${cacheHitPct}% hit (${_fmtTokens(cacheReadTok)} read / ${_fmtTokens(cacheWriteTok)} write)`:'';
let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`;
if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)';
if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
if(cacheText) label+=` \u00b7 ${cacheText}`;
el.setAttribute('aria-label',label);
const usageText=hasPromptTok?(overflowed?`${rawPct}% used (context exceeded)`:`${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`;
const tokensText=hasPromptTok?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`;
@@ -2182,6 +2188,11 @@ function _syncCtxIndicator(usage){
if(costLine){
if(cost){
costText=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
if(cacheText) costText+=` \u00b7 ${cacheText}`;
costLine.style.display='';
costLine.textContent=costText;
}else if(cacheText){
costText=cacheText;
costLine.style.display='';
costLine.textContent=costText;
}else{
@@ -6026,8 +6037,12 @@ function renderMessages(options){
const inTok=msg._turnUsage.input_tokens||0;
const outTok=msg._turnUsage.output_tokens||0;
const cost=msg._turnUsage.estimated_cost;
const cacheRead=msg._turnUsage.cache_read_tokens||0;
const cacheWrite=msg._turnUsage.cache_write_tokens||0;
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
const cacheTotal=cacheRead+cacheWrite;
if(cacheTotal) text+=` · cache ${Math.round((cacheRead/cacheTotal)*100)}% hit`;
usage.textContent=text;
fragments.push(usage);
}

View File

@@ -30,6 +30,8 @@ def test_stream_completion_overwrites_session_usage_with_latest_turn(cleanup_tes
self.input_tokens = 9000
self.output_tokens = 800
self.estimated_cost = 12.34
self.cache_read_tokens = 1000
self.cache_write_tokens = 200
self.tool_calls = []
self.gateway_routing = None
self.gateway_routing_history = []
@@ -48,6 +50,8 @@ def test_stream_completion_overwrites_session_usage_with_latest_turn(cleanup_tes
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"estimated_cost": self.estimated_cost,
"cache_read_tokens": self.cache_read_tokens,
"cache_write_tokens": self.cache_write_tokens,
"kwargs": kwargs,
}
)
@@ -67,6 +71,8 @@ def test_stream_completion_overwrites_session_usage_with_latest_turn(cleanup_tes
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"estimated_cost": self.estimated_cost,
"cache_read_tokens": self.cache_read_tokens,
"cache_write_tokens": self.cache_write_tokens,
"personality": self.personality,
}
@@ -93,6 +99,8 @@ def test_stream_completion_overwrites_session_usage_with_latest_turn(cleanup_tes
self.session_prompt_tokens = 123
self.session_completion_tokens = 45
self.session_estimated_cost_usd = 0.067
self.session_cache_read_tokens = 9000
self.session_cache_write_tokens = 1000
self.reasoning_config = None
self.ephemeral_system_prompt = None
self._last_error = None
@@ -169,13 +177,19 @@ def test_stream_completion_overwrites_session_usage_with_latest_turn(cleanup_tes
assert fake_session.input_tokens == 123
assert fake_session.output_tokens == 45
assert fake_session.estimated_cost == 0.067
assert fake_session.cache_read_tokens == 9000
assert fake_session.cache_write_tokens == 1000
assert any(
event == "done"
and payload["usage"]["input_tokens"] == 123
and payload["usage"]["output_tokens"] == 45
and payload["usage"]["estimated_cost"] == 0.067
and payload["usage"]["cache_read_tokens"] == 9000
and payload["usage"]["cache_write_tokens"] == 1000
for event, payload in list(fake_queue.queue)
)
assert saved_snapshots[-1]["input_tokens"] == 123
assert saved_snapshots[-1]["output_tokens"] == 45
assert saved_snapshots[-1]["estimated_cost"] == 0.067
assert saved_snapshots[-1]["cache_read_tokens"] == 9000
assert saved_snapshots[-1]["cache_write_tokens"] == 1000

View File

@@ -0,0 +1,50 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_session_compact_exposes_prompt_cache_counters():
from api.models import Session
session = Session(
session_id="issue2419_cache_usage",
workspace="/tmp",
input_tokens=120_000,
output_tokens=5_000,
estimated_cost=0.44,
cache_read_tokens=100_000,
cache_write_tokens=20_000,
)
compact = session.compact()
assert compact["cache_read_tokens"] == 100_000
assert compact["cache_write_tokens"] == 20_000
def test_streaming_usage_payload_includes_prompt_cache_counters():
src = (ROOT / "api" / "streaming.py").read_text()
assert "session_cache_read_tokens" in src
assert "session_cache_write_tokens" in src
assert "'cache_read_tokens': cache_read_tokens" in src
assert "'cache_write_tokens': cache_write_tokens" in src
def test_context_indicator_surfaces_cache_hit_rate():
src = (ROOT / "static" / "ui.js").read_text()
assert "cacheReadTok=usage.cache_read_tokens||0" in src
assert "cacheWriteTok=usage.cache_write_tokens||0" in src
assert "cache: ${cacheHitPct}% hit" in src
assert "Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}" in src
assert "cache ${Math.round((cacheRead/cacheTotal)*100)}% hit" in src
def test_done_handler_preserves_per_turn_cache_deltas():
src = (ROOT / "static" / "messages.js").read_text()
assert "_prevCacheRead=(S.session&&S.session.cache_read_tokens)||0" in src
assert "curCacheRead=d.usage.cache_read_tokens||0" in src
assert "cache_read_tokens:Math.max(0,curCacheRead-_prevCacheRead)" in src
assert "cache_write_tokens:Math.max(0,curCacheWrite-_prevCacheWrite)" in src