diff --git a/docs/content/public/pr-assets/pr-2245-cache-savings.png b/docs/content/public/pr-assets/pr-2245-cache-savings.png new file mode 100644 index 000000000..c56fabfb9 Binary files /dev/null and b/docs/content/public/pr-assets/pr-2245-cache-savings.png differ diff --git a/headroom/dashboard/templates/dashboard.html b/headroom/dashboard/templates/dashboard.html index 53e943bc8..42cffd8d6 100644 --- a/headroom/dashboard/templates/dashboard.html +++ b/headroom/dashboard/templates/dashboard.html @@ -289,6 +289,7 @@ x-text="formatNumber(row.events || 0) + ' calls ยท ' + (row.realized ? 'realized' : 'projected')"> + @@ -1669,15 +1670,16 @@ @@ -2279,18 +2282,21 @@ const row = totals[model] || (totals[model] = { model, tokens_saved: 0, - savings_usd: 0, + compression_savings_usd: 0, + cache_savings_usd: 0, input_cost_usd: 0, }); row.tokens_saved += entry.tokens_saved || 0; - row.savings_usd += entry.compression_savings_usd_delta || 0; + row.compression_savings_usd += entry.compression_savings_usd_delta || 0; + row.cache_savings_usd += entry.cache_savings_usd_delta || 0; row.input_cost_usd += entry.total_input_cost_usd_delta || 0; } } return Object.values(totals) .map(row => ({ ...row, - expected_cost_usd: row.input_cost_usd + row.savings_usd, + total_savings_usd: + row.compression_savings_usd + row.cache_savings_usd, })) .sort((a, b) => b.tokens_saved - a.tokens_saved); }, diff --git a/headroom/proxy/savings_tracker.py b/headroom/proxy/savings_tracker.py index 53102d81c..cb0489b7d 100644 --- a/headroom/proxy/savings_tracker.py +++ b/headroom/proxy/savings_tracker.py @@ -1671,6 +1671,7 @@ class SavingsTracker: aggregated: dict[str, dict[str, Any]] = {} prev_total_tokens = 0 prev_total_usd = 0.0 + prev_cache_savings_usd = 0.0 prev_total_input_tokens = 0 prev_total_input_cost_usd = 0.0 prev_output_tokens = 0 @@ -1686,12 +1687,14 @@ class SavingsTracker: bucket_key = _to_utc_iso(bucket_start) total_tokens_saved = _coerce_int(point.get("total_tokens_saved")) total_usd = _coerce_float(point.get("compression_savings_usd")) + cache_savings_usd = _coerce_float(point.get("cache_savings_usd")) total_input_tokens = _coerce_int(point.get("total_input_tokens")) total_input_cost_usd = _coerce_float(point.get("total_input_cost_usd")) total_output_tokens = _coerce_int(point.get("output_tokens_saved")) total_output_usd = _coerce_float(point.get("output_savings_usd")) delta_tokens = max(total_tokens_saved - prev_total_tokens, 0) delta_usd = max(total_usd - prev_total_usd, 0.0) + delta_cache_savings_usd = max(cache_savings_usd - prev_cache_savings_usd, 0.0) delta_input_tokens = max(total_input_tokens - prev_total_input_tokens, 0) delta_input_cost_usd = max( total_input_cost_usd - prev_total_input_cost_usd, @@ -1703,6 +1706,7 @@ class SavingsTracker: prev_total_tokens = total_tokens_saved prev_total_usd = total_usd + prev_cache_savings_usd = cache_savings_usd prev_total_input_tokens = total_input_tokens prev_total_input_cost_usd = total_input_cost_usd prev_output_tokens = total_output_tokens @@ -1714,6 +1718,7 @@ class SavingsTracker: "timestamp": bucket_key, "tokens_saved": 0, "compression_savings_usd_delta": 0.0, + "cache_savings_usd_delta": 0.0, "total_tokens_saved": total_tokens_saved, "compression_savings_usd": total_usd, "total_input_tokens_delta": 0, @@ -1731,6 +1736,10 @@ class SavingsTracker: entry["compression_savings_usd_delta"] + delta_usd, 6, ) + entry["cache_savings_usd_delta"] = round( + entry["cache_savings_usd_delta"] + delta_cache_savings_usd, + 6, + ) entry["total_input_tokens_delta"] += delta_input_tokens entry["total_input_cost_usd_delta"] = round( entry["total_input_cost_usd_delta"] + delta_input_cost_usd, @@ -1750,13 +1759,20 @@ class SavingsTracker: # it. Each checkpoint comes from a single request, so its delta is # wholly owned by one provider. Skip no-op checkpoints so providers # only appear in a bucket where they actually moved a counter. - if delta_tokens or delta_usd or delta_input_tokens or delta_input_cost_usd: + if ( + delta_tokens + or delta_usd + or delta_cache_savings_usd + or delta_input_tokens + or delta_input_cost_usd + ): provider = _normalize_provider(point.get("provider")) prov = entry["by_provider"].setdefault( provider, { "tokens_saved": 0, "compression_savings_usd_delta": 0.0, + "cache_savings_usd_delta": 0.0, "total_input_tokens_delta": 0, "total_input_cost_usd_delta": 0.0, }, @@ -1766,6 +1782,10 @@ class SavingsTracker: prov["compression_savings_usd_delta"] + delta_usd, 6, ) + prov["cache_savings_usd_delta"] = round( + prov["cache_savings_usd_delta"] + delta_cache_savings_usd, + 6, + ) prov["total_input_tokens_delta"] += delta_input_tokens prov["total_input_cost_usd_delta"] = round( prov["total_input_cost_usd_delta"] + delta_input_cost_usd, @@ -1778,6 +1798,7 @@ class SavingsTracker: { "tokens_saved": 0, "compression_savings_usd_delta": 0.0, + "cache_savings_usd_delta": 0.0, "total_input_tokens_delta": 0, "total_input_cost_usd_delta": 0.0, }, @@ -1787,6 +1808,10 @@ class SavingsTracker: mod["compression_savings_usd_delta"] + delta_usd, 6, ) + mod["cache_savings_usd_delta"] = round( + mod["cache_savings_usd_delta"] + delta_cache_savings_usd, + 6, + ) mod["total_input_tokens_delta"] += delta_input_tokens mod["total_input_cost_usd_delta"] = round( mod["total_input_cost_usd_delta"] + delta_input_cost_usd, diff --git a/tests/test_proxy_savings_history.py b/tests/test_proxy_savings_history.py index d40f0716d..41b6e64ad 100644 --- a/tests/test_proxy_savings_history.py +++ b/tests/test_proxy_savings_history.py @@ -1342,6 +1342,16 @@ def test_dashboard_includes_history_toggle_and_endpoint(tmp_path, monkeypatch): assert "Weekly Savings" in html assert "Monthly Savings" in html assert "Per-Model Breakdown" in html + breakdown_html = html.split("Per-Model Breakdown", 1)[1].split("Historical Summary", 1)[0] + assert "Compression saved" in breakdown_html + assert "Cache saved" in breakdown_html + assert "Total saved" in breakdown_html + assert "row.cache_savings_usd" in breakdown_html + assert "row.total_savings_usd" in breakdown_html + assert "overflow-x-auto" not in breakdown_html + assert 'class="w-full table-fixed text-xs"' in breakdown_html + assert "row.cache_savings_usd += entry.cache_savings_usd_delta || 0;" in html + assert "row.compression_savings_usd + row.cache_savings_usd" in html assert "historyChartModeOptions" in html assert "Expected cost (without Headroom)" in html assert "toggleHistoryModel" in html @@ -1472,6 +1482,9 @@ def test_cache_read_savings_accumulate_and_survive_restart(tmp_path, monkeypatch assert reloaded.snapshot()["lifetime"]["cache_savings_usd"] == pytest.approx(1.6) assert reloaded.stats_preview()["lifetime"]["cache_read_tokens"] == 1_600_000 assert reloaded.history_response()["lifetime"]["cache_read_tokens"] == 1_600_000 + daily = reloaded.history_response()["series"]["daily"] + assert daily[0]["cache_savings_usd_delta"] == pytest.approx(1.6) + assert daily[0]["by_model"]["claude-opus-4-8"]["cache_savings_usd_delta"] == pytest.approx(1.6) def test_by_model_savings_accumulate_and_survive_restart(tmp_path):