Skip to content

Commit 746c018

Browse files
committed
feat(hud): cache savings badge (Wave 2-C)
Claude API's prompt caching discounts cache_read_input_tokens by 90%. This module quantifies that saving so the HUD can surface "💰$4.56 saved" as a badge appended to the cost segment. New lib/hud_cache_savings.py: - compute_cache_savings(cache_read_tokens, model_id) -> float Pure arithmetic helper with defensive coercion for malformed input. - format_cache_savings(stdin_data) -> str End-to-end renderer that reads Claude Code stdin, extracts cache_read_input_tokens and model.id, returns "💰$N.NN saved" or "" below the $0.01 noise floor. - Known model families: haiku ($0.80/M), sonnet ($3/M), opus ($15/M). Unknown models fall back to sonnet-tier pricing (conservative). 26 new tests in test_hud_cache_savings.py cover: - All 3 model families + unknown + empty - Case-insensitive matching - Zero / negative / non-numeric / numeric-string inputs - Empty stdin / missing context_window / missing current_usage - Below $0.01 noise floor - display_name fallback when model.id is absent - Two-decimal formatting Part of #1464 (Wave 0 statusbar refactor)
1 parent 956ea51 commit 746c018

2 files changed

Lines changed: 314 additions & 14 deletions

File tree

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,119 @@
1-
"""Cache-savings badge for CodingBuddy statusLine (#1326).
1+
"""Cache savings calculator for CodingBuddy statusLine (#1326, Wave 2-C).
22
3-
Wave 0 skeleton — reserved for **Wave 2-C**.
3+
Claude API's prompt caching charges ``cache_read_input_tokens`` at
4+
10% of the base input price — a 90% discount. This module quantifies
5+
that discount so the HUD can surface "how much you saved by caching"
6+
as a badge like ``"💰$4.56 saved"`` appended to the cost segment.
47
5-
Planned contents (Wave 2-C owner fills):
6-
* ``compute_cache_savings(cost_breakdown: dict) -> float`` — USD
7-
avoided by cache hits
8-
* ``format_cache_savings_badge(savings_usd: float) -> str``
8+
Primary entry points:
99
10-
Source of truth for the computation is the stdin ``cost`` payload
11-
(cached-input vs non-cached-input token counts combined with
12-
``MODEL_PRICING`` — both already available in ``codingbuddy-hud``).
13-
This module will be the single import target for Wave 3 assembly.
10+
- :func:`compute_cache_savings` — pure arithmetic helper (tokens +
11+
model_id → dollars saved).
12+
- :func:`format_cache_savings` — end-to-end renderer that reads
13+
Claude Code stdin, extracts the relevant fields, and returns the
14+
formatted badge string (or ``""`` when there is nothing to show).
1415
"""
16+
from __future__ import annotations
17+
18+
from typing import Any, Dict
19+
20+
# Money glyph — U+1F4B0 money bag emoji
21+
_MONEY_GLYPH: str = "\U0001f4b0" # 💰
22+
23+
# cache_read tokens cost 10% of the input price, so the per-token
24+
# savings equals 90% of the input price.
25+
_CACHE_DISCOUNT: float = 0.90
26+
27+
# Minimum dollar savings required to show the badge. Hides noise
28+
# below one cent so the status bar does not flicker on tiny reads.
29+
_MIN_DISPLAY_USD: float = 0.01
30+
31+
# Baseline input prices in USD per million tokens. Mirrors the
32+
# ``MODEL_PRICING`` table in ``codingbuddy-hud.py``.
33+
_INPUT_PRICE_PER_M: Dict[str, float] = {
34+
"haiku": 0.80,
35+
"sonnet": 3.00,
36+
"opus": 15.00,
37+
}
38+
39+
# Sonnet as the safe default when the model family cannot be
40+
# identified. Avoids over-claiming savings on unknown tiers.
41+
_DEFAULT_INPUT_PRICE_PER_M: float = 3.00
42+
43+
44+
def _input_price_per_million(model_id: str) -> float:
45+
"""Return the baseline input price (USD per million tokens).
46+
47+
Case-insensitive substring match against the known family keys.
48+
Falls back to the sonnet tier when no key matches.
49+
"""
50+
if not model_id:
51+
return _DEFAULT_INPUT_PRICE_PER_M
52+
lowered = model_id.lower()
53+
for key, price in _INPUT_PRICE_PER_M.items():
54+
if key in lowered:
55+
return price
56+
return _DEFAULT_INPUT_PRICE_PER_M
57+
58+
59+
def compute_cache_savings(
60+
cache_read_tokens: Any,
61+
model_id: str,
62+
) -> float:
63+
"""Return the dollar amount saved by cache reads.
64+
65+
Formula::
66+
67+
savings = cache_read_tokens * (input_price / 1_000_000) * 0.90
68+
69+
Defensive coercion: negative or non-numeric inputs return
70+
``0.0`` so callers never render a "saved -$0.12" surprise when
71+
upstream payloads are malformed.
72+
"""
73+
try:
74+
tokens = int(cache_read_tokens)
75+
except (TypeError, ValueError):
76+
return 0.0
77+
if tokens <= 0:
78+
return 0.0
79+
price = _input_price_per_million(model_id)
80+
return (tokens / 1_000_000.0) * price * _CACHE_DISCOUNT
81+
82+
83+
def format_cache_savings(stdin_data: Dict[str, Any]) -> str:
84+
"""Render the cache savings badge from a stdin payload.
85+
86+
Output format:
87+
88+
``💰$4.56 saved``
89+
90+
Returns an empty string when any of the following hold:
91+
92+
* ``stdin_data`` is empty or has no ``context_window``
93+
* ``current_usage`` is missing
94+
* ``cache_read_input_tokens`` is zero, absent, or negative
95+
* Computed savings < ``$0.01`` (noise floor)
96+
97+
Model identification is sourced from ``stdin_data.model.id``
98+
(or ``display_name`` fallback). Unknown models default to the
99+
sonnet-tier input price so the display still shows a
100+
conservative estimate.
101+
"""
102+
if not stdin_data:
103+
return ""
104+
105+
ctx = stdin_data.get("context_window") or {}
106+
usage = ctx.get("current_usage") or {}
107+
cache_read = usage.get("cache_read_input_tokens", 0) or 0
108+
109+
if not cache_read or (isinstance(cache_read, (int, float)) and cache_read <= 0):
110+
return ""
111+
112+
model_info = stdin_data.get("model") or {}
113+
model_id = model_info.get("id") or model_info.get("display_name") or ""
114+
115+
savings = compute_cache_savings(cache_read, model_id)
116+
if savings < _MIN_DISPLAY_USD:
117+
return ""
118+
119+
return f"{_MONEY_GLYPH}${savings:.2f} saved"
Lines changed: 199 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Skeleton sanity for hud_cache_savings Wave 2-C placeholder (#1463)."""
1+
"""Behavior tests for hud_cache_savings (Wave 2-C / #1326)."""
22
import os
33
import sys
44

@@ -9,7 +9,202 @@
99
if _p not in sys.path:
1010
sys.path.insert(0, _p)
1111

12+
import hud_cache_savings # noqa: E402
1213

13-
def test_module_loads():
14-
"""Contract: hud_cache_savings must be importable. Wave 2-C will add real assertions."""
15-
import hud_cache_savings # noqa: F401
14+
_MONEY = "\U0001f4b0" # 💰
15+
16+
17+
# --------------------------- _input_price_per_million ----------------------
18+
19+
20+
def test_input_price_haiku():
21+
assert hud_cache_savings._input_price_per_million("claude-haiku-4-5") == 0.80
22+
23+
24+
def test_input_price_sonnet():
25+
assert hud_cache_savings._input_price_per_million("claude-sonnet-4-6") == 3.00
26+
27+
28+
def test_input_price_opus():
29+
assert hud_cache_savings._input_price_per_million("claude-opus-4-6") == 15.00
30+
31+
32+
def test_input_price_unknown_defaults_to_sonnet():
33+
assert hud_cache_savings._input_price_per_million("gpt-4") == 3.00
34+
35+
36+
def test_input_price_empty_defaults():
37+
assert hud_cache_savings._input_price_per_million("") == 3.00
38+
39+
40+
def test_input_price_case_insensitive():
41+
assert hud_cache_savings._input_price_per_million("CLAUDE-OPUS-4") == 15.00
42+
43+
44+
# --------------------------- compute_cache_savings ------------------------
45+
46+
47+
def test_compute_zero_tokens_returns_zero():
48+
assert hud_cache_savings.compute_cache_savings(0, "opus") == 0.0
49+
50+
51+
def test_compute_negative_tokens_returns_zero():
52+
assert hud_cache_savings.compute_cache_savings(-100, "opus") == 0.0
53+
54+
55+
def test_compute_non_numeric_returns_zero():
56+
assert hud_cache_savings.compute_cache_savings("abc", "opus") == 0.0
57+
assert hud_cache_savings.compute_cache_savings(None, "opus") == 0.0
58+
59+
60+
def test_compute_opus_savings():
61+
"""1M cache_read tokens on opus → 1M * $15/M * 0.9 = $13.50 saved."""
62+
result = hud_cache_savings.compute_cache_savings(1_000_000, "claude-opus")
63+
assert abs(result - 13.50) < 0.001
64+
65+
66+
def test_compute_sonnet_savings():
67+
"""1M cache_read tokens on sonnet → 1M * $3/M * 0.9 = $2.70 saved."""
68+
result = hud_cache_savings.compute_cache_savings(1_000_000, "claude-sonnet")
69+
assert abs(result - 2.70) < 0.001
70+
71+
72+
def test_compute_haiku_savings():
73+
"""1M cache_read tokens on haiku → 1M * $0.80/M * 0.9 = $0.72 saved."""
74+
result = hud_cache_savings.compute_cache_savings(1_000_000, "claude-haiku")
75+
assert abs(result - 0.72) < 0.001
76+
77+
78+
def test_compute_scales_linearly():
79+
"""Double the tokens → double the savings."""
80+
a = hud_cache_savings.compute_cache_savings(100_000, "opus")
81+
b = hud_cache_savings.compute_cache_savings(200_000, "opus")
82+
assert abs(b - 2 * a) < 0.001
83+
84+
85+
def test_compute_numeric_string_accepted():
86+
"""Numeric string coerced via int()."""
87+
result = hud_cache_savings.compute_cache_savings("500000", "sonnet")
88+
assert result > 0
89+
90+
91+
# --------------------------- format_cache_savings -------------------------
92+
93+
94+
def test_format_empty_stdin_returns_empty():
95+
assert hud_cache_savings.format_cache_savings({}) == ""
96+
97+
98+
def test_format_no_context_window_returns_empty():
99+
assert hud_cache_savings.format_cache_savings({"cost": {}}) == ""
100+
101+
102+
def test_format_no_current_usage_returns_empty():
103+
stdin = {"context_window": {}}
104+
assert hud_cache_savings.format_cache_savings(stdin) == ""
105+
106+
107+
def test_format_zero_cache_read_returns_empty():
108+
stdin = {
109+
"context_window": {
110+
"current_usage": {"cache_read_input_tokens": 0}
111+
}
112+
}
113+
assert hud_cache_savings.format_cache_savings(stdin) == ""
114+
115+
116+
def test_format_missing_cache_read_returns_empty():
117+
stdin = {
118+
"context_window": {
119+
"current_usage": {"input_tokens": 1000}
120+
}
121+
}
122+
assert hud_cache_savings.format_cache_savings(stdin) == ""
123+
124+
125+
def test_format_below_one_cent_returns_empty():
126+
"""Tiny savings (< $0.01) are hidden to avoid flicker."""
127+
stdin = {
128+
"context_window": {
129+
"current_usage": {"cache_read_input_tokens": 100}
130+
},
131+
"model": {"id": "claude-sonnet"},
132+
}
133+
# 100 tokens * $3/M * 0.9 = $0.00027 → below threshold
134+
result = hud_cache_savings.format_cache_savings(stdin)
135+
assert result == ""
136+
137+
138+
def test_format_meaningful_savings_opus():
139+
"""500K cache_read tokens on opus → $6.75 saved."""
140+
stdin = {
141+
"context_window": {
142+
"current_usage": {"cache_read_input_tokens": 500_000}
143+
},
144+
"model": {"id": "claude-opus-4-6"},
145+
}
146+
result = hud_cache_savings.format_cache_savings(stdin)
147+
assert result.startswith(_MONEY)
148+
assert "6.75" in result
149+
assert "saved" in result
150+
151+
152+
def test_format_uses_display_name_fallback():
153+
"""When model.id is empty, fall back to display_name for pricing."""
154+
stdin = {
155+
"context_window": {
156+
"current_usage": {"cache_read_input_tokens": 1_000_000}
157+
},
158+
"model": {"display_name": "Opus 4.6"},
159+
}
160+
result = hud_cache_savings.format_cache_savings(stdin)
161+
assert "13.50" in result
162+
163+
164+
def test_format_unknown_model_uses_sonnet_default():
165+
"""Unknown model → sonnet-tier pricing ($2.70 per 1M tokens)."""
166+
stdin = {
167+
"context_window": {
168+
"current_usage": {"cache_read_input_tokens": 1_000_000}
169+
},
170+
"model": {"id": "some-unknown"},
171+
}
172+
result = hud_cache_savings.format_cache_savings(stdin)
173+
assert "2.70" in result
174+
175+
176+
def test_format_uses_money_glyph():
177+
stdin = {
178+
"context_window": {
179+
"current_usage": {"cache_read_input_tokens": 1_000_000}
180+
},
181+
"model": {"id": "opus"},
182+
}
183+
result = hud_cache_savings.format_cache_savings(stdin)
184+
assert result.startswith(_MONEY)
185+
186+
187+
def test_format_two_decimal_places():
188+
"""Output always has 2 decimal places."""
189+
stdin = {
190+
"context_window": {
191+
"current_usage": {"cache_read_input_tokens": 100_000}
192+
},
193+
"model": {"id": "opus"},
194+
}
195+
result = hud_cache_savings.format_cache_savings(stdin)
196+
# Should look like "💰$1.35 saved"
197+
import re
198+
199+
assert re.search(r"\$\d+\.\d{2} saved", result)
200+
201+
202+
def test_format_negative_tokens_returns_empty():
203+
"""Malformed payload with negative cache_read is silently skipped."""
204+
stdin = {
205+
"context_window": {
206+
"current_usage": {"cache_read_input_tokens": -500}
207+
},
208+
"model": {"id": "opus"},
209+
}
210+
assert hud_cache_savings.format_cache_savings(stdin) == ""

0 commit comments

Comments
 (0)