claude-usage-widget 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Claude Usage Widget — desktop usage tracker for Claude Code."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,149 @@
1
+ """Anomaly detection and cost-optimisation analysis over usage history.
2
+
3
+ Pure module — no I/O, no GUI, no network. Given a list of sample dicts from
4
+ history.py, produces structured reports the widget can render in the popup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import statistics
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ MIN_SAMPLES = 7 # Need at least a week of data before we flag anomalies
14
+ Z_THRESHOLD = 2.0 # Standard deviations above the mean
15
+
16
+
17
+ @dataclass
18
+ class AnomalyReport:
19
+ """Summary of a single-day anomaly check."""
20
+
21
+ is_anomaly: bool = False
22
+ today_usage: float = 0.0
23
+ baseline: float = 0.0
24
+ std_dev: float = 0.0
25
+ z_score: float = 0.0
26
+ ratio: float = 0.0
27
+ reason: str = ""
28
+ message: str = ""
29
+
30
+
31
+ def _daily_peaks(samples: list[dict], key: str = "session") -> list[float]:
32
+ """Reduce a sample stream into one max value per calendar day."""
33
+ by_day: dict[int, float] = {}
34
+ for s in samples:
35
+ ts = float(s.get("ts", 0))
36
+ if ts <= 0:
37
+ continue
38
+ day = int(ts // 86400)
39
+ val = float(s.get(key, 0))
40
+ if val > by_day.get(day, 0.0):
41
+ by_day[day] = val
42
+ return [by_day[d] for d in sorted(by_day)]
43
+
44
+
45
+ def detect_anomaly(
46
+ samples: list[dict],
47
+ today_usage: float,
48
+ key: str = "session",
49
+ ) -> AnomalyReport:
50
+ """Return an AnomalyReport for today_usage against historical peaks."""
51
+ rep = AnomalyReport(today_usage=today_usage)
52
+
53
+ peaks = _daily_peaks(samples, key=key)
54
+ history = peaks[:-1] if peaks else []
55
+
56
+ if len(history) < MIN_SAMPLES:
57
+ rep.reason = f"insufficient history ({len(history)} days < {MIN_SAMPLES})"
58
+ return rep
59
+
60
+ rep.baseline = statistics.fmean(history)
61
+ rep.std_dev = statistics.pstdev(history) if len(history) > 1 else 0.0
62
+ if rep.baseline > 0:
63
+ rep.ratio = today_usage / rep.baseline
64
+ if rep.std_dev > 0:
65
+ rep.z_score = (today_usage - rep.baseline) / rep.std_dev
66
+
67
+ # Flag anomaly: either z-score exceeds threshold, OR history is flat
68
+ # (std_dev == 0) but today is >= 1.5x the baseline.
69
+ is_spike = today_usage > rep.baseline and (
70
+ rep.z_score >= Z_THRESHOLD
71
+ or (rep.std_dev == 0 and rep.ratio >= 1.5)
72
+ )
73
+ if is_spike:
74
+ rep.is_anomaly = True
75
+ rep.message = (
76
+ f"Today is {rep.ratio:.1f}x your {len(history)}-day average — "
77
+ f"{int(today_usage * 100)}% vs {int(rep.baseline * 100)}% typical."
78
+ )
79
+ return rep
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Cost optimisation tips
84
+ # ---------------------------------------------------------------------------
85
+
86
+ LOW_CACHE_HIT_RATE = 0.60 # below this, suggest improving caching
87
+ OPUS_HEAVY_THRESHOLD = 0.80 # above this share of output from opus, suggest sonnet
88
+
89
+
90
+ def _cache_hit_rate(counts: dict) -> float:
91
+ """Return cache_read / (cache_read + input) for one model's counts."""
92
+ cr = float(counts.get("cache_read", 0) or 0)
93
+ in_t = float(counts.get("input", 0) or 0)
94
+ denom = cr + in_t
95
+ return cr / denom if denom > 0 else 0.0
96
+
97
+
98
+ def generate_tips(
99
+ by_model: dict,
100
+ week_cost: float,
101
+ cache_savings: float,
102
+ ) -> list[str]:
103
+ """Return 0-3 short actionable tips based on the week's usage profile."""
104
+ tips: list[str] = []
105
+ if not by_model:
106
+ return tips
107
+
108
+ total_output = sum(
109
+ float(c.get("output", 0) or 0) for c in by_model.values()
110
+ )
111
+
112
+ # Tip 1: cache hit rate
113
+ hit_rates = [
114
+ _cache_hit_rate(c) for c in by_model.values()
115
+ if float(c.get("input", 0) or 0) + float(c.get("cache_read", 0) or 0) > 10_000
116
+ ]
117
+ if hit_rates:
118
+ avg_hit = sum(hit_rates) / len(hit_rates)
119
+ if avg_hit < LOW_CACHE_HIT_RATE and week_cost > 0:
120
+ potential = week_cost * (0.85 - avg_hit) * 0.9
121
+ if potential >= 1.0:
122
+ tips.append(
123
+ f"Cache hit rate is {int(avg_hit * 100)}%. "
124
+ f"Raising to ~85% could save ~${potential:.0f}/week."
125
+ )
126
+
127
+ # Tip 2: model mix
128
+ if total_output > 100_000:
129
+ opus_output = sum(
130
+ float(c.get("output", 0) or 0)
131
+ for m, c in by_model.items() if "opus" in m
132
+ )
133
+ opus_share = opus_output / total_output if total_output else 0.0
134
+ if opus_share >= OPUS_HEAVY_THRESHOLD and week_cost > 20.0:
135
+ potential = week_cost * 0.70 * (opus_share - 0.5) * 0.4
136
+ if potential >= 1.0:
137
+ tips.append(
138
+ f"Opus handles {int(opus_share * 100)}% of your output. "
139
+ f"Shifting easy tasks to Sonnet could save ~${potential:.0f}/week."
140
+ )
141
+
142
+ # Tip 3: celebrate savings
143
+ if cache_savings > 0 and week_cost > 0 and cache_savings >= week_cost * 2:
144
+ tips.append(
145
+ f"Cache already saves ${cache_savings:.0f}/week — "
146
+ f"{cache_savings / max(week_cost, 1):.1f}x your bill. Keep it up."
147
+ )
148
+
149
+ return tips[:3]
@@ -0,0 +1,86 @@
1
+ """Localhost-only JSON HTTP server exposing UsageStats.
2
+
3
+ Runs on a background thread so the GTK / rumps main loop is never blocked.
4
+ Binds only to 127.0.0.1 by default; callers must explicitly opt into a
5
+ non-loopback address via config (and then understand the auth implications).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import threading
12
+ from dataclasses import asdict, is_dataclass
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from typing import Callable
15
+
16
+ from claude_usage.collector import UsageStats
17
+
18
+
19
+ class UsageAPIServer:
20
+ """Background HTTP server exposing /usage and /healthz."""
21
+
22
+ def __init__(
23
+ self,
24
+ host: str,
25
+ port: int,
26
+ get_stats: Callable[[], UsageStats],
27
+ ) -> None:
28
+ self.host = host
29
+ self._requested_port = port
30
+ self._get_stats = get_stats
31
+ self._server: ThreadingHTTPServer | None = None
32
+ self._thread: threading.Thread | None = None
33
+
34
+ @property
35
+ def port(self) -> int:
36
+ if self._server is None:
37
+ return self._requested_port
38
+ return self._server.server_address[1]
39
+
40
+ def start(self) -> None:
41
+ """Bind the socket and start the background serving thread."""
42
+ handler_cls = self._make_handler()
43
+ self._server = ThreadingHTTPServer((self.host, self._requested_port), handler_cls)
44
+ self._thread = threading.Thread(
45
+ target=self._server.serve_forever, name="usage-api", daemon=True,
46
+ )
47
+ self._thread.start()
48
+
49
+ def stop(self) -> None:
50
+ """Shut down the server and wait for its thread to exit."""
51
+ if self._server is not None:
52
+ self._server.shutdown()
53
+ self._server.server_close()
54
+ self._server = None
55
+ if self._thread is not None:
56
+ self._thread.join(timeout=2)
57
+ self._thread = None
58
+
59
+ def _make_handler(self) -> type[BaseHTTPRequestHandler]:
60
+ get_stats = self._get_stats
61
+
62
+ class Handler(BaseHTTPRequestHandler):
63
+ def log_message(self, fmt, *args) -> None: # noqa: N802
64
+ return
65
+
66
+ def do_GET(self) -> None: # noqa: N802
67
+ if self.path == "/healthz":
68
+ self._send_json({"ok": True})
69
+ return
70
+ if self.path == "/usage":
71
+ stats = get_stats()
72
+ data = asdict(stats) if is_dataclass(stats) else dict(stats)
73
+ self._send_json(data)
74
+ return
75
+ self.send_error(404, "Not Found")
76
+
77
+ def _send_json(self, payload: dict) -> None:
78
+ body = json.dumps(payload, default=str).encode()
79
+ self.send_response(200)
80
+ self.send_header("Content-Type", "application/json")
81
+ self.send_header("Content-Length", str(len(body)))
82
+ self.send_header("Cache-Control", "no-store")
83
+ self.end_headers()
84
+ self.wfile.write(body)
85
+
86
+ return Handler
claude_usage/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """Command-line interface for headless / scripted access to usage stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from dataclasses import asdict, is_dataclass
10
+ from typing import Sequence
11
+
12
+ from claude_usage import __version__
13
+ from claude_usage.collector import UsageStats, collect_all
14
+ from claude_usage.config import load_config
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ """Return the argparse parser used by the CLI dispatcher."""
19
+ p = argparse.ArgumentParser(
20
+ prog="claude-usage",
21
+ description="Claude Code usage tracker — GUI by default, CLI on demand.",
22
+ )
23
+ p.add_argument("--version", action="store_true", help="Print version and exit.")
24
+ p.add_argument("--json", action="store_true", help="Emit full stats as JSON.")
25
+ p.add_argument("--once", action="store_true", help="Collect once and print JSON.")
26
+ p.add_argument("--field", metavar="NAME", default=None,
27
+ help="Print a single UsageStats field by name.")
28
+ p.add_argument("--export", choices=("csv", "json"), default=None,
29
+ help="Export history as CSV or JSON to stdout.")
30
+ p.add_argument("--days", type=int, default=30,
31
+ help="Look-back window for --export (default: 30).")
32
+ return p
33
+
34
+
35
+ def _usage_stats_to_dict(stats: UsageStats) -> dict:
36
+ """Convert a UsageStats dataclass to a JSON-serialisable dict."""
37
+ return asdict(stats) if is_dataclass(stats) else dict(stats)
38
+
39
+
40
+ def _default_config_path() -> str:
41
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
42
+ cfg = os.path.join(base_dir, "config.json")
43
+ if not os.path.isfile(cfg):
44
+ cfg = os.path.join(base_dir, "config.json.example")
45
+ return cfg
46
+
47
+
48
+ def run_cli(argv: Sequence[str]) -> int:
49
+ """Dispatch a single CLI invocation. Returns a process exit code."""
50
+ args = build_parser().parse_args(list(argv))
51
+
52
+ if args.version:
53
+ print(__version__)
54
+ return 0
55
+
56
+ if args.export:
57
+ from claude_usage.exporter import export_history
58
+ config = load_config(_default_config_path())
59
+ history_path = os.path.join(config["claude_dir"], "usage-history.jsonl")
60
+ count = export_history(history_path, fmt=args.export, days=args.days, out=sys.stdout)
61
+ print(f"# exported {count} samples", file=sys.stderr)
62
+ return 0
63
+
64
+ if args.json or args.once or args.field:
65
+ config = load_config(_default_config_path())
66
+ stats = collect_all(config)
67
+ data = _usage_stats_to_dict(stats)
68
+
69
+ if args.field is not None:
70
+ if args.field not in data:
71
+ print(f"error: unknown field {args.field!r}", file=sys.stderr)
72
+ return 2
73
+ print(data[args.field])
74
+ return 0
75
+
76
+ json.dump(data, sys.stdout, default=str, indent=2, sort_keys=True)
77
+ print()
78
+ return 0
79
+
80
+ # No CLI flag — caller should fall through to GUI.
81
+ return -1
82
+
83
+
84
+ def main() -> int:
85
+ """Entry point for the ``claude-usage`` console script."""
86
+ return run_cli(sys.argv[1:])
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())