diff --git a/api/models.py b/api/models.py index 746ce62b..84c21438 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/streaming.py b/api/streaming.py index 52eb3202..ee3f714f 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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: diff --git a/static/messages.js b/static/messages.js index 7fb9ebf4..12b43b7a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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'){ diff --git a/static/ui.js b/static/ui.js index ec77553b..bbee01f7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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); } diff --git a/tests/test_issue1857_usage_overwrite.py b/tests/test_issue1857_usage_overwrite.py index 485a4847..f642158a 100644 --- a/tests/test_issue1857_usage_overwrite.py +++ b/tests/test_issue1857_usage_overwrite.py @@ -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 diff --git a/tests/test_issue2419_cache_usage_display.py b/tests/test_issue2419_cache_usage_display.py new file mode 100644 index 00000000..7d5804a8 --- /dev/null +++ b/tests/test_issue2419_cache_usage_display.py @@ -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