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.
- claude_usage/__init__.py +3 -0
- claude_usage/analytics.py +149 -0
- claude_usage/api_server.py +86 -0
- claude_usage/cli.py +90 -0
- claude_usage/collector.py +613 -0
- claude_usage/config.py +93 -0
- claude_usage/exporter.py +86 -0
- claude_usage/forecast.py +155 -0
- claude_usage/history.py +95 -0
- claude_usage/icons/claude-tray.svg +17 -0
- claude_usage/notifier.py +78 -0
- claude_usage/overlay.py +556 -0
- claude_usage/overlay_macos.py +673 -0
- claude_usage/pricing.py +175 -0
- claude_usage/py.typed +0 -0
- claude_usage/themes.py +146 -0
- claude_usage/trends.py +106 -0
- claude_usage/updater.py +52 -0
- claude_usage/webhooks.py +67 -0
- claude_usage/widget.py +899 -0
- claude_usage/widget_macos.py +1223 -0
- claude_usage_widget-0.2.0.dist-info/METADATA +238 -0
- claude_usage_widget-0.2.0.dist-info/RECORD +27 -0
- claude_usage_widget-0.2.0.dist-info/WHEEL +5 -0
- claude_usage_widget-0.2.0.dist-info/entry_points.txt +2 -0
- claude_usage_widget-0.2.0.dist-info/licenses/LICENSE +21 -0
- claude_usage_widget-0.2.0.dist-info/top_level.txt +1 -0
claude_usage/__init__.py
ADDED
|
@@ -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())
|