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 `
`; }).join('') + @@ -4071,11 +4079,15 @@ function _renderInsights(d, box, wikiStatus, skillUsage) { // Models table let modelsHtml = ''; if (d.models && d.models.length) { - modelsHtml = `