diff --git a/api/routes.py b/api/routes.py index b7f581e2..b4daa03b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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, diff --git a/static/i18n.js b/static/i18n.js index 9e312c68..b7437cb3 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/panels.js b/static/panels.js index 751ed896..397ce14f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 `
${showLabel ? esc(labelText) : ''}
`; }).join('') + @@ -4071,11 +4079,15 @@ function _renderInsights(d, box, wikiStatus, skillUsage) { // Models table let modelsHtml = ''; if (d.models && d.models.length) { - modelsHtml = `
${esc(t('insights_models'))}
${esc(t('insights_model_name'))}${esc(t('insights_model_sessions'))}${esc(t('insights_model_tokens'))}${esc(t('insights_model_cost'))}${esc(t('insights_model_share'))}
` + + modelsHtml = `
${esc(t('insights_models'))}
${esc(t('insights_model_name'))}${esc(t('insights_model_sessions'))}${esc(t('insights_model_tokens'))}${esc(t('insights_model_cache'))}${esc(t('insights_model_cost'))}${esc(t('insights_model_share'))}
` + 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 `
${esc(m.model)}${fmtNum(m.sessions)}${fmtTokens(m.total_tokens || 0)}${fmtCost(m.cost)}${share}%
`; + const cachePct = (m.cache_hit_percent === null || m.cache_hit_percent === undefined) ? null : Number(m.cache_hit_percent); + const cacheCell = cachePct === null + ? '—' + : `${cachePct}%`; + return `
${esc(m.model)}${fmtNum(m.sessions)}${fmtTokens(m.total_tokens || 0)}${cacheCell}${fmtCost(m.cost)}${share}%
`; }).join('') + `
`; } else { diff --git a/static/style.css b/static/style.css index 9d54b288..4d404572 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } } diff --git a/tests/test_insights.py b/tests/test_insights.py index df9adfd3..a4594db5 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -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 +