Merge pull request #5114 from nesquena/salvage/3912-cache-hit-insights

feat(insights): surface prompt-cache hit rate (salvage of #3912)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-28 01:24:27 -07:00
commit 9bf9508c0f
5 files changed
+198 -15

No files matched your search

+56 -7
View File
@@ -8471,6 +8471,8 @@ def _handle_insights(handler, parsed) -> bool:
import collections
import time as _time
from api.usage import prompt_cache_hit_percent
query = parse_qs(parsed.query)
try:
days = min(max(int(query.get("days", ["30"])[0]), 1), 365)
@@ -8529,6 +8531,7 @@ def _handle_insights(handler, parsed) -> bool:
total_messages = 0
total_input_tokens = 0
total_output_tokens = 0
total_cache_read_tokens = 0
total_cost = 0.0
model_stats: dict[str, dict] = {}
daily_tokens: dict[str, dict] = {}
@@ -8540,10 +8543,12 @@ def _handle_insights(handler, parsed) -> bool:
for s in sessions_data:
input_tokens = _safe_usage_int(s.get("input_tokens"))
output_tokens = _safe_usage_int(s.get("output_tokens"))
cache_read_tokens = _safe_usage_int(s.get("cache_read_tokens"))
cost_value = _safe_cost_float(s.get("estimated_cost"))
total_messages += _safe_usage_int(s.get("message_count"))
total_input_tokens += input_tokens
total_output_tokens += output_tokens
total_cache_read_tokens += cache_read_tokens
total_cost += cost_value
model = s.get("model") or "unknown"
@@ -8551,11 +8556,13 @@ def _handle_insights(handler, parsed) -> bool:
"sessions": 0,
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"cost": 0.0,
})
bucket["sessions"] += 1
bucket["input_tokens"] += input_tokens
bucket["output_tokens"] += output_tokens
bucket["cache_read_tokens"] += cache_read_tokens
bucket["cost"] += cost_value
# Activity patterns
@@ -8567,11 +8574,13 @@ def _handle_insights(handler, parsed) -> bool:
daily_bucket = daily_tokens.setdefault(day_key, {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"sessions": 0,
"cost": 0.0,
})
daily_bucket["input_tokens"] += input_tokens
daily_bucket["output_tokens"] += output_tokens
daily_bucket["cache_read_tokens"] += cache_read_tokens
daily_bucket["sessions"] += 1
daily_bucket["cost"] += cost_value
dow_activity[dt.tm_wday] += 1
@@ -8587,22 +8596,39 @@ def _handle_insights(handler, parsed) -> bool:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("""
SELECT id, model, message_count, input_tokens, output_tokens,
estimated_cost_usd, started_at, ended_at
FROM sessions
WHERE (started_at >= ? OR ended_at >= ?)
AND COALESCE(source, '') != 'webui'
""", (cutoff, cutoff))
# cache_read_tokens may not exist on older agent state DBs;
# fall back to a query without it if the column is missing.
try:
cur.execute("""
SELECT id, model, message_count, input_tokens, output_tokens,
estimated_cost_usd,
COALESCE(cache_read_tokens, 0) AS cache_read_tokens,
started_at, ended_at
FROM sessions
WHERE (started_at >= ? OR ended_at >= ?)
AND COALESCE(source, '') != 'webui'
""", (cutoff, cutoff))
except sqlite3.OperationalError:
cur.execute("""
SELECT id, model, message_count, input_tokens, output_tokens,
estimated_cost_usd,
0 AS cache_read_tokens,
started_at, ended_at
FROM sessions
WHERE (started_at >= ? OR ended_at >= ?)
AND COALESCE(source, '') != 'webui'
""", (cutoff, cutoff))
for row in cur.fetchall():
_input = _safe_usage_int(row["input_tokens"])
_output = _safe_usage_int(row["output_tokens"])
_cache_read = _safe_usage_int(row["cache_read_tokens"])
_cost = _safe_cost_float(row["estimated_cost_usd"])
_msgs = _safe_usage_int(row["message_count"])
total_sessions += 1
total_messages += _msgs
total_input_tokens += _input
total_output_tokens += _output
total_cache_read_tokens += _cache_read
total_cost += _cost
_model = row["model"] or "unknown"
@@ -8610,11 +8636,13 @@ def _handle_insights(handler, parsed) -> bool:
"sessions": 0,
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"cost": 0.0,
})
bucket["sessions"] += 1
bucket["input_tokens"] += _input
bucket["output_tokens"] += _output
bucket["cache_read_tokens"] += _cache_read
bucket["cost"] += _cost
_ts = row["started_at"] or row["ended_at"] or 0
@@ -8624,11 +8652,13 @@ def _handle_insights(handler, parsed) -> bool:
_daily = daily_tokens.setdefault(_day_key, {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"sessions": 0,
"cost": 0.0,
})
_daily["input_tokens"] += _input
_daily["output_tokens"] += _output
_daily["cache_read_tokens"] += _cache_read
_daily["sessions"] += 1
_daily["cost"] += _cost
dow_activity[_dt.tm_wday] += 1
@@ -8642,11 +8672,22 @@ def _handle_insights(handler, parsed) -> bool:
for model, stats in model_stats.items():
row_total_tokens = stats["input_tokens"] + stats["output_tokens"]
row_cost = round(stats["cost"], 6)
row_cache_read = stats["cache_read_tokens"]
# Bounded prompt-cache hit rate: cached reads over the FULL prompt total
# (ordinary input + cache reads), so it can never exceed 100%. Computing
# cache_read / input_tokens alone would overshoot 100% on cache-heavy
# sessions. prompt_cache_hit_percent clamps to [0,100] and returns None
# when there is nothing meaningful to display.
row_cache_hit_percent = prompt_cache_hit_percent(
row_cache_read, stats["input_tokens"] + row_cache_read
)
models_breakdown.append({
"model": model,
"sessions": stats["sessions"],
"input_tokens": stats["input_tokens"],
"output_tokens": stats["output_tokens"],
"cache_read_tokens": row_cache_read,
"cache_hit_percent": row_cache_hit_percent,
"total_tokens": row_total_tokens,
"cost": row_cost,
"session_share": int(round((stats["sessions"] / total_sessions) * 100)) if total_sessions else 0,
@@ -8662,6 +8703,7 @@ def _handle_insights(handler, parsed) -> bool:
bucket = daily_tokens.get(day_key, {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"sessions": 0,
"cost": 0.0,
})
@@ -8669,6 +8711,7 @@ def _handle_insights(handler, parsed) -> bool:
"date": day_key,
"input_tokens": bucket["input_tokens"],
"output_tokens": bucket["output_tokens"],
"cache_read_tokens": bucket.get("cache_read_tokens", 0),
"sessions": bucket["sessions"],
"cost": round(bucket["cost"], 6),
})
@@ -8686,6 +8729,12 @@ def _handle_insights(handler, parsed) -> bool:
"total_messages": total_messages,
"total_input_tokens": total_input_tokens,
"total_output_tokens": total_output_tokens,
"total_cache_read_tokens": total_cache_read_tokens,
# Aggregate prompt-cache hit rate, bounded 0-100% via the shared helper
# (cache_read over input + cache_read).
"total_cache_hit_percent": prompt_cache_hit_percent(
total_cache_read_tokens, total_input_tokens + total_cache_read_tokens
),
"total_tokens": total_tokens,
"total_cost": round(total_cost, 6),
"models": models_breakdown,
+26
View File
@@ -1050,6 +1050,8 @@ const LOCALES = {
insights_model_sessions: 'Sessions',
insights_model_tokens: 'Tokens',
insights_model_cost: 'Cost',
insights_model_cache: 'Cache',
insights_cache_hit: 'Prompt-cache hit rate',
insights_model_share: 'Share',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -2681,6 +2683,8 @@ const LOCALES = {
insights_model_sessions: 'Sessioni',
insights_model_tokens: 'Token',
insights_model_cost: 'Costo',
insights_model_cache: 'Cache',
insights_cache_hit: 'Tasso di hit della cache prompt',
insights_model_share: 'Quota',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -4313,6 +4317,8 @@ const LOCALES = {
insights_model_sessions: 'セッション',
insights_model_tokens: 'トークン',
insights_model_cost: 'コスト',
insights_model_cache: 'キャッシュ',
insights_cache_hit: 'プロンプトキャッシュのヒット率',
insights_model_share: 'シェア',
insights_model_health_title: 'モデルヘルスの比較',
insights_model_health_provider: 'プロバイダー',
@@ -6426,6 +6432,8 @@ const LOCALES = {
insights_model_sessions: 'Sessions',
insights_model_tokens: 'Tokens',
insights_model_cost: 'Cost',
insights_model_cache: 'Кэш',
insights_cache_hit: 'Доля попаданий в кэш промптов',
insights_model_share: 'Share',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -7977,6 +7985,8 @@ const LOCALES = {
insights_model_sessions: 'Sessions',
insights_model_tokens: 'Tokens',
insights_model_cost: 'Cost',
insights_model_cache: 'Caché',
insights_cache_hit: 'Tasa de aciertos de caché de prompts',
insights_model_share: 'Share',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -9544,6 +9554,8 @@ const LOCALES = {
insights_model_sessions: 'Sessions',
insights_model_tokens: 'Tokens',
insights_model_cost: 'Cost',
insights_model_cache: 'Cache',
insights_cache_hit: 'Trefferquote des Prompt-Caches',
insights_model_share: 'Share',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -11104,6 +11116,8 @@ const LOCALES = {
insights_model_sessions: '会话',
insights_model_tokens: '令牌',
insights_model_cost: '费用',
insights_model_cache: '缓存',
insights_cache_hit: '提示缓存命中率',
insights_model_share: '占比',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -12199,6 +12213,8 @@ const LOCALES = {
insights_model_sessions: '對話',
insights_model_tokens: 'Token',
insights_model_cost: '成本',
insights_model_cache: '快取',
insights_cache_hit: '提示快取命中率',
insights_model_share: '占比',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -15801,6 +15817,8 @@ const LOCALES = {
insights_model_sessions: '세션',
insights_model_tokens: '토큰',
insights_model_cost: '비용',
insights_model_cache: '캐시',
insights_cache_hit: '프롬프트 캐시 적중률',
insights_model_share: '비율',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -16883,6 +16901,8 @@ const LOCALES = {
insights_model_sessions: 'Sessions',
insights_model_tokens: 'Jetons',
insights_model_cost: 'Coût',
insights_model_cache: 'Cache',
insights_cache_hit: 'Taux de succès du cache de prompts',
insights_model_share: 'Part',
insights_model_health_title: 'Comparaison de la santé des modèles',
insights_model_health_provider: 'Fournisseur',
@@ -19033,6 +19053,8 @@ const LOCALES = {
insights_model_sessions: 'Oturumlar',
insights_model_tokens: 'Jetonlar',
insights_model_cost: 'Maliyet',
insights_model_cache: 'Önbellek',
insights_cache_hit: 'İstem önbelleği isabet oranı',
insights_model_share: 'Paylaşmak',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
@@ -20116,6 +20138,8 @@ const LOCALES = {
insights_model_sessions: 'Sesje',
insights_model_tokens: 'Tokeny',
insights_model_cost: 'Koszt',
insights_model_cache: 'Cache',
insights_cache_hit: 'Współczynnik trafień pamięci podręcznej promptów',
insights_model_share: 'Udział',
insights_no_usage_data: 'Brak danych o zużyciu',
insights_footer: 'Wyświetlanie danych z ostatnich {days} dni',
@@ -21588,6 +21612,8 @@ const LOCALES = {
insights_model_sessions: 'Phiên',
insights_model_tokens: 'Token',
insights_model_cost: 'Chi phí',
insights_model_cache: 'Cache',
insights_cache_hit: 'Tỷ lệ trúng bộ nhớ đệm prompt',
insights_model_share: 'Tỷ lệ',
insights_no_usage_data: 'Chưa có dữ liệu sử dụng',
insights_footer: 'Hiển thị dữ liệu trong {days} ngày gần nhất',
+15 -3
View File
@@ -3982,6 +3982,7 @@ function _bucketDailyTokensForChart(rows) {
const slice = rows.slice(i, i + bucketSize);
const input_tokens = slice.reduce((s, r) => s + Number(r.input_tokens || 0), 0);
const output_tokens = slice.reduce((s, r) => s + Number(r.output_tokens || 0), 0);
const cache_read_tokens = slice.reduce((s, r) => s + Number(r.cache_read_tokens || 0), 0);
const sessions = slice.reduce((s, r) => s + Number(r.sessions || 0), 0);
const cost = slice.reduce((s, r) => s + Number(r.cost || 0), 0);
@@ -3999,6 +4000,7 @@ function _bucketDailyTokensForChart(rows) {
date: firstDate,
input_tokens,
output_tokens,
cache_read_tokens,
sessions,
cost,
});
@@ -4059,7 +4061,13 @@ function _renderInsights(d, box, wikiStatus, skillUsage) {
const outputPct = Math.max((output / maxDailyTokens) * 100, output ? 2 : 0).toFixed(1);
const showLabel = idx === 0 || idx === chartRows.length - 1 || idx % labelEvery === 0;
const titleDate = r.title || r.date;
const title = `${titleDate} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`;
const cacheRead = Number(r.cache_read_tokens || 0);
// Bounded daily cache hit rate: reads / (input + reads), 0-100%.
const cacheDenom = input + cacheRead;
const cacheStr = (cacheRead > 0 && cacheDenom > 0)
? ` · ${Math.min(100, Math.round((cacheRead / cacheDenom) * 100))}% ${t('insights_model_cache')}`
: '';
const title = `${titleDate} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')}${cacheStr} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`;
const labelText = r.label !== undefined ? r.label : String(r.date).slice(5);
return `<div class="insights-daily-bar" title="${esc(title)}"><div class="insights-daily-stack" aria-label="${esc(title)}"><div class="insights-daily-bar-output" style="height:${outputPct}%"></div><div class="insights-daily-bar-input" style="height:${inputPct}%"></div></div><span>${showLabel ? esc(labelText) : ''}</span></div>`;
}).join('') +
@@ -4071,11 +4079,15 @@ function _renderInsights(d, box, wikiStatus, skillUsage) {
// Models table
let modelsHtml = '';
if (d.models && d.models.length) {
modelsHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_models'))}</div><div class="insights-table insights-model-table"><div class="insights-table-head"><span>${esc(t('insights_model_name'))}</span><span>${esc(t('insights_model_sessions'))}</span><span>${esc(t('insights_model_tokens'))}</span><span>${esc(t('insights_model_cost'))}</span><span>${esc(t('insights_model_share'))}</span></div>` +
modelsHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_models'))}</div><div class="insights-table insights-model-table"><div class="insights-table-head"><span>${esc(t('insights_model_name'))}</span><span>${esc(t('insights_model_sessions'))}</span><span>${esc(t('insights_model_tokens'))}</span><span title="${esc(t('insights_cache_hit'))}">${esc(t('insights_model_cache'))}</span><span>${esc(t('insights_model_cost'))}</span><span>${esc(t('insights_model_share'))}</span></div>` +
d.models.map(m => {
const share = Number(m.cost_share || m.token_share || m.session_share || 0);
const title = `${m.model} · ${fmtTokens(m.input_tokens)} ${t('insights_input_tokens')} · ${fmtTokens(m.output_tokens)} ${t('insights_output_tokens')}`;
return `<div class="insights-table-row"><span class="insights-model-name" title="${esc(m.model)}">${esc(m.model)}</span><span>${fmtNum(m.sessions)}</span><span class="insights-model-tokens" title="${esc(title)}">${fmtTokens(m.total_tokens || 0)}</span><span class="insights-model-cost">${fmtCost(m.cost)}</span><span>${share}%</span></div>`;
const cachePct = (m.cache_hit_percent === null || m.cache_hit_percent === undefined) ? null : Number(m.cache_hit_percent);
const cacheCell = cachePct === null
? '<span class="insights-model-cache insights-model-cache-empty">—</span>'
: `<span class="insights-model-cache" title="${esc(t('insights_cache_hit'))}: ${fmtTokens(m.cache_read_tokens || 0)}">${cachePct}%</span>`;
return `<div class="insights-table-row"><span class="insights-model-name" title="${esc(m.model)}">${esc(m.model)}</span><span>${fmtNum(m.sessions)}</span><span class="insights-model-tokens" title="${esc(title)}">${fmtTokens(m.total_tokens || 0)}</span>${cacheCell}<span class="insights-model-cost">${fmtCost(m.cost)}</span><span>${share}%</span></div>`;
}).join('') +
`</div></div>`;
} else {
+4 -2
View File
@@ -6126,7 +6126,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.insights-table{width:100%;font-size:12px;}
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
.insights-table-row{display:grid;grid-template-columns:1fr 80px;padding:6px 0;border-bottom:1px solid var(--border,.05);}
.insights-model-table .insights-table-head,.insights-model-table .insights-table-row{grid-template-columns:minmax(90px,1.5fr) 64px 70px 68px 48px;gap:8px;align-items:center;}
.insights-model-table .insights-table-head,.insights-model-table .insights-table-row{grid-template-columns:minmax(80px,1.4fr) 56px 64px 54px 64px 44px;gap:8px;align-items:center;}
.insights-model-health-card{padding:0;}
.insights-model-health-card>summary{cursor:pointer;padding:12px 14px;}
.insights-model-health-card>summary .insights-card-title{display:inline-block;margin-bottom:0;}
@@ -6136,6 +6136,8 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.insights-model-health-table .insights-table-head,.insights-model-health-table .insights-table-row{grid-template-columns:minmax(130px,1.5fr) 86px 92px minmax(150px,1.4fr);gap:8px;align-items:center;min-width:540px;}
.insights-model-health-replacement{overflow-wrap:anywhere;color:var(--muted);}
.insights-model-cost,.insights-model-tokens{font-variant-numeric:tabular-nums;font-weight:700;color:var(--text);}
.insights-model-cache{font-variant-numeric:tabular-nums;font-weight:700;color:var(--text);}
.insights-model-cache-empty{color:var(--muted);font-weight:400;}
.insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;color:var(--text);}
.insights-empty{font-size:12px;color:var(--muted);padding:12px 0;}
.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);overflow:hidden;max-width:100%;}
@@ -6170,7 +6172,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
overflow-x: auto;
}
.insights-model-table {
min-width: 320px;
min-width: 360px;
}
}
+97 -3
View File
@@ -96,6 +96,7 @@ def test_insights_daily_tokens_zero_fills_selected_range_and_parses_cost(monkeyp
"date": _day(now),
"input_tokens": 1200,
"output_tokens": 300,
"cache_read_tokens": 0,
"sessions": 1,
"cost": 0.0123,
}
@@ -103,6 +104,7 @@ def test_insights_daily_tokens_zero_fills_selected_range_and_parses_cost(monkeyp
"date": _day(now - 86400),
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"sessions": 0,
"cost": 0.0,
}
@@ -130,6 +132,8 @@ def test_insights_model_breakdown_tracks_tokens_cost_and_shares(monkeypatch, tmp
assert costly["output_tokens"] == 50
assert costly["total_tokens"] == 150
assert costly["cost"] == 0.2
assert costly["cache_read_tokens"] == 0
assert costly["cache_hit_percent"] is None
assert costly["session_share"] == 33
assert costly["token_share"] == 18
assert costly["cost_share"] == 80
@@ -147,6 +151,8 @@ def test_insights_frontend_renders_daily_token_chart_and_model_usage_table():
assert "insights-daily-bar-input" in PANELS_JS
assert "insights-daily-bar-output" in PANELS_JS
assert "insights_model_tokens" in PANELS_JS
assert "insights_model_cache" in PANELS_JS
assert "insights_cache_hit" in PANELS_JS
assert "insights_model_cost" in PANELS_JS
assert "insights_model_share" in PANELS_JS
assert "insights_model_team" not in PANELS_JS
@@ -346,17 +352,19 @@ def _call_insights_with_state_db(monkeypatch, tmp_path, entries, state_rows, day
"""CREATE TABLE sessions (
id TEXT PRIMARY KEY, source TEXT, model TEXT, message_count INTEGER,
input_tokens INTEGER, output_tokens INTEGER, estimated_cost_usd REAL,
cache_read_tokens INTEGER DEFAULT 0,
started_at REAL, ended_at REAL
)"""
)
for r in state_rows:
conn.execute(
"INSERT INTO sessions (id, source, model, message_count, input_tokens, "
"output_tokens, estimated_cost_usd, started_at, ended_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
"output_tokens, estimated_cost_usd, cache_read_tokens, started_at, ended_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(r["id"], r.get("source"), r.get("model"), r.get("message_count", 0),
r.get("input_tokens", 0), r.get("output_tokens", 0),
r.get("estimated_cost_usd", 0.0), r.get("started_at"), r.get("ended_at")),
r.get("estimated_cost_usd", 0.0), r.get("cache_read_tokens", 0),
r.get("started_at"), r.get("ended_at")),
)
conn.commit()
conn.close()
@@ -414,3 +422,89 @@ def test_insights_does_not_double_count_webui_state_db_rows(monkeypatch, tmp_pat
assert data["total_sessions"] == 2
# webui tokens counted once (100) + cli (200) = 300, NOT 400.
assert data["total_input_tokens"] == 300
# ── #3911 / salvage of #3912: prompt-cache hit rate on Insights ────────────
# Maintainer-flagged correctness fix: the hit rate must use the FULL prompt
# total as the denominator — cache_read / (input_tokens + cache_read) — so it
# is bounded 0-100% and means "% of the prompt served from cache". The naive
# cache_read / input_tokens formula EXCEEDS 100% on cache-heavy sessions.
def test_insights_cache_hit_rate_uses_bounded_denominator(monkeypatch, tmp_path):
"""Per-model + aggregate hit rate = cache_read / (input + cache_read)."""
now = time.mktime((2026, 5, 4, 12, 0, 0, 0, 0, -1))
entries = [
# 100 fresh input + 300 cached reads -> 300 / (100 + 300) = 75%
{"updated_at": now, "message_count": 1, "model": "cached",
"input_tokens": 100, "output_tokens": 40, "cache_read_tokens": 300,
"estimated_cost": 0.05},
]
data = _call_insights(monkeypatch, tmp_path, entries, days="7", now=now)
model = data["models"][0]
assert model["model"] == "cached"
assert model["cache_read_tokens"] == 300
# cache_read / (input + cache_read) = 300 / 400 = 75 (NOT 300/100 = 300%)
assert model["cache_hit_percent"] == 75
# Aggregate uses the same bounded denominator.
assert data["total_cache_read_tokens"] == 300
assert data["total_cache_hit_percent"] == 75
def test_insights_cache_hit_rate_never_exceeds_100_percent(monkeypatch, tmp_path):
"""Non-vacuous boundary test: an extremely cache-heavy session where the OLD
formula (cache_read / input_tokens) would report >100%. The bounded
denominator must clamp the displayed rate to <=100%.
Here cache_read (9900) vastly exceeds fresh input (100):
OLD (buggy): 9900 / 100 = 9900% ← would be displayed, absurd
NEW (fixed): 9900 / (100+9900) = 99% ← bounded, meaningful
The assertion below would FAIL under the old formula, so it is non-vacuous.
"""
now = time.mktime((2026, 5, 4, 12, 0, 0, 0, 0, -1))
entries = [
{"updated_at": now, "message_count": 1, "model": "cache-heavy",
"input_tokens": 100, "output_tokens": 20, "cache_read_tokens": 9900,
"estimated_cost": 0.10},
# A second model with cache_read == input (would be exactly 100% under
# the old formula; 50% under the bounded one) — still <= 100% either way,
# included to exercise the equality edge of the bound.
{"updated_at": now, "message_count": 1, "model": "balanced",
"input_tokens": 500, "output_tokens": 100, "cache_read_tokens": 500,
"estimated_cost": 0.02},
]
data = _call_insights(monkeypatch, tmp_path, entries, days="7", now=now)
# Prove non-vacuity: the OLD reads-over-misses formula WOULD exceed 100%.
heavy = next(m for m in data["models"] if m["model"] == "cache-heavy")
old_formula = round((heavy["cache_read_tokens"] / heavy["input_tokens"]) * 100)
assert old_formula > 100, "test must exercise a case the old formula mishandles"
# Every per-model displayed rate is bounded to 0..100.
for m in data["models"]:
pct = m["cache_hit_percent"]
if pct is not None:
assert 0 <= pct <= 100, f"{m['model']} hit rate {pct} out of bounds"
# The fixed bounded denominator: 9900 / (100 + 9900) = 99%.
assert heavy["cache_hit_percent"] == 99
balanced = next(m for m in data["models"] if m["model"] == "balanced")
assert balanced["cache_hit_percent"] == 50
# Aggregate is bounded too, and < 100 even though cache_read dominates input.
assert data["total_cache_hit_percent"] is not None
assert 0 <= data["total_cache_hit_percent"] <= 100
def test_insights_cache_hit_rate_is_none_without_cache_reads(monkeypatch, tmp_path):
"""No cache reads -> hit rate is None (not 0%), so the column shows '—'."""
now = time.mktime((2026, 5, 4, 12, 0, 0, 0, 0, -1))
entries = [
{"updated_at": now, "message_count": 1, "model": "nocache",
"input_tokens": 500, "output_tokens": 100, "estimated_cost": 0.03},
]
data = _call_insights(monkeypatch, tmp_path, entries, days="7", now=now)
assert data["models"][0]["cache_read_tokens"] == 0
assert data["models"][0]["cache_hit_percent"] is None
assert data["total_cache_hit_percent"] is None