Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/docs/proxy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ curl "http://localhost:8787/stats-history?format=csv&series=weekly"

### `GET /stats/sessions/{session_id}`

Per-session savings for one harness session id, taken from the `x-claude-code-session-id` request header (or `x-headroom-session-id`). Same row shape as a per-project row from `/stats-lifetime`; loopback-only, like `/admin` and `/debug`. Requests without either header are counted globally but never attributed to a session.
Per-session savings for one harness session id, taken from the `x-claude-code-session-id` request header (or `x-headroom-session-id`). Same row shape as a per-project row from `/stats-lifetime`, which carries two percentages: `savings_percent`, everything saved over everything that would have been sent, so a large request weighs more than a small one, and `avg_request_savings_percent`, the mean of each request's own saving with every request weighing the same (`null` for a row whose requests predate the field, until new ones arrive). Loopback-only, like `/admin` and `/debug`. Requests without either header are counted globally but never attributed to a session.

```bash
curl http://localhost:8787/stats/sessions/0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0
Expand Down
137 changes: 98 additions & 39 deletions headroom/proxy/savings_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,10 +769,76 @@ def _empty_project_entry() -> dict[str, Any]:
"compression_savings_usd": 0.0,
"total_input_tokens": 0,
"total_input_cost_usd": 0.0,
# The mean of each request's own saving percentage needs the percentages themselves, which the totals above cannot give back: the sum of them, and how many requests contributed to it. The count is separate from ``requests`` because an entry persisted before these fields existed has requests the sum never saw, and dividing by ``requests`` would understate the mean for good.
"request_savings_percent_sum": 0.0,
"request_savings_percent_requests": 0,
"last_activity_at": None,
}


def _entry_view(entry: dict[str, Any]) -> dict[str, Any]:
"""A per-project or per-session entry as the stats endpoints present it, with its two derived percentages.

``savings_percent`` is the overall figure: everything saved over everything that would have been sent, so a large request weighs more than a small one. ``avg_request_savings_percent`` is the mean of each request's own saving, every request weighing the same, and is ``None`` for an entry no request has contributed a percentage to (one persisted before the accumulators existed, until new requests arrive).
"""
view = dict(entry)
total_before = entry["tokens_saved"] + entry["total_input_tokens"]
view["savings_percent"] = round(
(entry["tokens_saved"] / total_before * 100) if total_before > 0 else 0.0,
2,
)
contributing = entry["request_savings_percent_requests"]
view["avg_request_savings_percent"] = (
round(entry["request_savings_percent_sum"] / contributing, 2) if contributing > 0 else None
)
return view


def _accumulate_entry(
entry: dict[str, Any],
*,
timestamp_dt: datetime,
requests_delta: int,
tokens_saved_delta: int,
savings_usd_delta: float,
input_tokens_delta: int,
input_cost_usd_delta: float,
request_savings_percent: float,
) -> None:
"""Fold one request's deltas into a per-project or per-session entry.

``request_savings_percent`` is that request's own saving, in the same terms as the entry's overall figure (saved over saved plus input sent), added to a running sum with a count of how many requests contributed so the mean of the per-request figures can be read back.
"""
entry["requests"] += max(requests_delta, 0)
entry["tokens_saved"] += max(tokens_saved_delta, 0)
entry["compression_savings_usd"] = round(
entry["compression_savings_usd"] + savings_usd_delta, 6
)
entry["total_input_tokens"] += max(input_tokens_delta, 0)
entry["total_input_cost_usd"] = round(
entry["total_input_cost_usd"] + max(input_cost_usd_delta, 0.0), 6
)
if requests_delta > 0:
entry["request_savings_percent_sum"] = round(
entry["request_savings_percent_sum"] + request_savings_percent, 6
)
entry["request_savings_percent_requests"] += requests_delta
entry["last_activity_at"] = _to_utc_iso(timestamp_dt)


def _normalize_request_savings_percent(normalized: dict[str, Any], entry: dict[str, Any]) -> None:
"""Carry a persisted entry's per-request percentage accumulators onto ``normalized``.

Absent in state files written before the fields existed, which read as no requests contributing, so such an entry shows no mean until new requests accumulate rather than a mean over requests the sum never saw.
"""
normalized["request_savings_percent_sum"] = round(
_coerce_float(entry.get("request_savings_percent_sum")), 6
)
normalized["request_savings_percent_requests"] = _coerce_int(
entry.get("request_savings_percent_requests")
)


def _normalize_projects(raw: Any) -> dict[str, dict[str, Any]]:
if not isinstance(raw, dict):
return {}
Expand All @@ -782,6 +848,7 @@ def _normalize_projects(raw: Any) -> dict[str, dict[str, Any]]:
if cleaned_name is None or not isinstance(entry, dict):
continue
normalized = _empty_project_entry()
_normalize_request_savings_percent(normalized, entry)
normalized["requests"] = _coerce_int(entry.get("requests"))
normalized["tokens_saved"] = _coerce_int(entry.get("tokens_saved"))
normalized["compression_savings_usd"] = round(
Expand Down Expand Up @@ -825,6 +892,7 @@ def _normalize_sessions(raw: Any) -> dict[str, dict[str, Any]]:
if key is None or not isinstance(entry, dict):
continue
normalized = _empty_session_entry()
_normalize_request_savings_percent(normalized, entry)
normalized["requests"] = _coerce_int(entry.get("requests"))
normalized["tokens_saved"] = _coerce_int(entry.get("tokens_saved"))
normalized["compression_savings_usd"] = round(
Expand Down Expand Up @@ -1154,6 +1222,11 @@ def record_request(
delta_tokens_saved = _coerce_int(tokens_saved)
delta_tool_tokens_saved = max(_coerce_int(tool_search_saved), 0)
delta_input_tokens = _coerce_int(input_tokens)
# This request's own saving in the entries' terms (saved over saved plus input sent), so a mean of these and the entries' overall figure are measured the same way.
request_before = delta_tokens_saved + delta_input_tokens
request_savings_percent = (
delta_tokens_saved / request_before * 100 if request_before > 0 else 0.0
)
delta_output_tokens_saved = max(_coerce_int(output_tokens_saved), 0)
delta_cache_read_tokens = _coerce_int(cache_read_tokens)
priced = estimated_savings_usd
Expand Down Expand Up @@ -1383,6 +1456,7 @@ def record_request(
savings_usd_delta=delta_savings_usd,
input_tokens_delta=delta_input_tokens,
input_cost_usd_delta=delta_input_cost_usd,
request_savings_percent=request_savings_percent,
)

self._record_session_locked(
Expand All @@ -1393,6 +1467,7 @@ def record_request(
savings_usd_delta=delta_savings_usd,
input_tokens_delta=delta_input_tokens,
input_cost_usd_delta=delta_input_cost_usd,
request_savings_percent=request_savings_percent,
)

# In --mode cache, headroom's own compression (tokens_saved) is
Expand Down Expand Up @@ -1540,6 +1615,7 @@ def _record_project_locked(
savings_usd_delta: float = 0.0,
input_tokens_delta: int = 0,
input_cost_usd_delta: float = 0.0,
request_savings_percent: float = 0.0,
) -> None:
"""Accumulate per-project savings. Caller must hold ``self._lock``.

Expand All @@ -1552,16 +1628,16 @@ def _record_project_locked(
return
projects: dict[str, dict[str, Any]] = self._state.setdefault("projects", {})
entry = projects.setdefault(name, _empty_project_entry())
entry["requests"] += max(requests_delta, 0)
entry["tokens_saved"] += max(tokens_saved_delta, 0)
entry["compression_savings_usd"] = round(
entry["compression_savings_usd"] + savings_usd_delta, 6
_accumulate_entry(
entry,
timestamp_dt=timestamp_dt,
requests_delta=requests_delta,
tokens_saved_delta=tokens_saved_delta,
savings_usd_delta=savings_usd_delta,
input_tokens_delta=input_tokens_delta,
input_cost_usd_delta=input_cost_usd_delta,
request_savings_percent=request_savings_percent,
)
entry["total_input_tokens"] += max(input_tokens_delta, 0)
entry["total_input_cost_usd"] = round(
entry["total_input_cost_usd"] + max(input_cost_usd_delta, 0.0), 6
)
entry["last_activity_at"] = _to_utc_iso(timestamp_dt)
if len(projects) > DEFAULT_MAX_PROJECTS:
evict = min(
(key for key in projects if key != name),
Expand All @@ -1582,6 +1658,7 @@ def _record_session_locked(
savings_usd_delta: float = 0.0,
input_tokens_delta: int = 0,
input_cost_usd_delta: float = 0.0,
request_savings_percent: float = 0.0,
) -> None:
"""Accumulate per-session savings. Caller must hold ``self._lock``.

Expand All @@ -1602,16 +1679,16 @@ def _record_session_locked(
]:
del sessions[idle_key]
entry = sessions.setdefault(key, _empty_session_entry())
entry["requests"] += max(requests_delta, 0)
entry["tokens_saved"] += max(tokens_saved_delta, 0)
entry["compression_savings_usd"] = round(
entry["compression_savings_usd"] + savings_usd_delta, 6
)
entry["total_input_tokens"] += max(input_tokens_delta, 0)
entry["total_input_cost_usd"] = round(
entry["total_input_cost_usd"] + max(input_cost_usd_delta, 0.0), 6
_accumulate_entry(
entry,
timestamp_dt=timestamp_dt,
requests_delta=requests_delta,
tokens_saved_delta=tokens_saved_delta,
savings_usd_delta=savings_usd_delta,
input_tokens_delta=input_tokens_delta,
input_cost_usd_delta=input_cost_usd_delta,
request_savings_percent=request_savings_percent,
)
entry["last_activity_at"] = _to_utc_iso(timestamp_dt)
if len(sessions) > DEFAULT_MAX_SESSIONS:
# Evict the least recently active session, not the smallest saver: sessions are a recency-scoped index (an operator asks "what did this run save"), so recency is the only honest eviction order. String compare on the ISO activity stamp is a time compare.
evict = min(
Expand Down Expand Up @@ -1660,13 +1737,7 @@ def _projects_snapshot_locked(self) -> dict[str, dict[str, Any]]:
)
result: dict[str, dict[str, Any]] = {}
for name, entry in ranked:
view = dict(entry)
total_before = entry["tokens_saved"] + entry["total_input_tokens"]
view["savings_percent"] = round(
(entry["tokens_saved"] / total_before * 100) if total_before > 0 else 0.0,
2,
)
result[name] = view
result[name] = _entry_view(entry)
return result

def _sessions_snapshot_locked(self) -> dict[str, dict[str, Any]]:
Expand All @@ -1679,13 +1750,7 @@ def _sessions_snapshot_locked(self) -> dict[str, dict[str, Any]]:
)
result: dict[str, dict[str, Any]] = {}
for session_id, entry in ranked:
view = dict(entry)
total_before = entry["tokens_saved"] + entry["total_input_tokens"]
view["savings_percent"] = round(
(entry["tokens_saved"] / total_before * 100) if total_before > 0 else 0.0,
2,
)
result[session_id] = view
result[session_id] = _entry_view(entry)
return result

def session_response(self, session_id: str) -> dict[str, Any] | None:
Expand All @@ -1700,13 +1765,7 @@ def session_response(self, session_id: str) -> dict[str, Any] | None:
entry = self._state.get("sessions", {}).get(key)
if not isinstance(entry, dict):
return None
view = dict(entry)
total_before = entry["tokens_saved"] + entry["total_input_tokens"]
view["savings_percent"] = round(
(entry["tokens_saved"] / total_before * 100) if total_before > 0 else 0.0,
2,
)
return view
return _entry_view(entry)

def _by_model_snapshot_locked(self) -> dict[str, dict[str, Any]]:
"""Per-model stats ranked by savings."""
Expand Down
91 changes: 91 additions & 0 deletions tests/test_request_savings_percent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for the mean of each request's own saving percentage on session and project rows."""

import json
from datetime import datetime, timezone

from headroom.proxy.savings_tracker import SavingsTracker

UTC = timezone.utc

# Two requests whose own savings differ: one saves 10 of 100 (10%), the other 0 of 1000 (0%). The mean of the two is 5%, while the overall figure (10 saved of 1100 before) is far lower.
SMALL_SAVING = {"input_tokens": 90, "tokens_saved": 10}
LARGE_NO_SAVING = {"input_tokens": 1000, "tokens_saved": 0}


def _record_two(tracker: SavingsTracker) -> None:
tracker.record_request(model="gpt-4o", session_id="s", project="p", **SMALL_SAVING)
tracker.record_request(model="gpt-4o", session_id="s", project="p", **LARGE_NO_SAVING)


def test_session_row_carries_the_mean_of_per_request_percentages_beside_the_overall_one(tmp_path):
tracker = SavingsTracker(path=str(tmp_path / "savings.json"))
_record_two(tracker)

row = tracker.session_response("s")

assert row is not None
# Every request weighs the same: (10% + 0%) / 2.
assert row["avg_request_savings_percent"] == 5.0
# The overall figure weighs by size: 10 saved over 10 + 90 + 0 + 1000 before.
assert row["savings_percent"] == 0.91


def test_project_row_carries_the_same_mean_as_the_session_row(tmp_path):
tracker = SavingsTracker(path=str(tmp_path / "savings.json"))
_record_two(tracker)

project_row = tracker.lifetime_response()["projects"]["p"]

assert project_row["avg_request_savings_percent"] == 5.0


def test_the_accumulators_survive_a_reload(tmp_path):
path = tmp_path / "savings.json"
_record_two(SavingsTracker(path=str(path)))

reloaded = SavingsTracker(path=str(path))

row = reloaded.session_response("s")
assert row is not None
assert row["avg_request_savings_percent"] == 5.0
# A request after the reload extends the same mean: (10% + 0% + 20%) / 3.
reloaded.record_request(
model="gpt-4o", session_id="s", project="p", input_tokens=80, tokens_saved=20
)
extended = reloaded.session_response("s")
assert extended is not None
assert extended["avg_request_savings_percent"] == 10.0


def test_an_entry_persisted_before_the_accumulators_has_no_mean_until_requests_contribute(tmp_path):
path = tmp_path / "savings.json"
path.write_text(
json.dumps(
{
"schema_version": 6,
"lifetime": {},
"display_session": None,
"history": [],
"sessions": {
"legacy": {
"requests": 500,
"tokens_saved": 5000,
"total_input_tokens": 100000,
"last_activity_at": datetime.now(UTC).isoformat(),
}
},
}
)
)
tracker = SavingsTracker(path=str(path))

legacy = tracker.session_response("legacy")
assert legacy is not None
# The 500 earlier requests contributed no percentages, so there is no mean to report, rather than one dragged down by requests it never saw.
assert legacy["avg_request_savings_percent"] is None

tracker.record_request(model="gpt-4o", session_id="legacy", input_tokens=90, tokens_saved=10)
resumed = tracker.session_response("legacy")
assert resumed is not None
# Only the one request that has contributed counts, not the 501 the entry has seen.
assert resumed["avg_request_savings_percent"] == 10.0
Loading