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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'){
|
||||
|
||||
17
static/ui.js
17
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
50
tests/test_issue2419_cache_usage_display.py
Normal file
50
tests/test_issue2419_cache_usage_display.py
Normal 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
|
||||
Reference in New Issue
Block a user