claude-calc 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hassan Ibrahim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-calc
3
+ Version: 0.1.0
4
+ Summary: Price your Claude Code session logs and view the spend in a local dashboard
5
+ Author-email: Hassan Ibrahim <eng.hibrahem@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hibrahem/claude-calc
8
+ Project-URL: Issues, https://github.com/hibrahem/claude-calc/issues
9
+ Keywords: claude,claude-code,anthropic,cost,tokens,dashboard
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # claude-calc
21
+
22
+ See what your Claude Code sessions would cost at pay-per-token API rates.
23
+
24
+ It reads the session logs Claude Code keeps in `~/.claude/projects`, prices
25
+ every API message, and shows the result in a local dashboard: spend over time,
26
+ cost per model split by token type, and projects you can expand down to
27
+ individual sessions.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install claude-calc
33
+ ```
34
+
35
+ or `pip install claude-calc`. Python 3.9 or newer, no other dependencies.
36
+
37
+ ## Use
38
+
39
+ ```bash
40
+ claude-calc
41
+ ```
42
+
43
+ Starts the dashboard on http://localhost:8765 and opens it in your browser.
44
+ Logs are rescanned on every page load and on the Rescan button.
45
+
46
+ ```bash
47
+ claude-calc report
48
+ ```
49
+
50
+ Prints a plain-text summary instead.
51
+
52
+ Options:
53
+
54
+ | Flag | Meaning |
55
+ |---|---|
56
+ | `--port 9000` | serve on a different port |
57
+ | `--no-browser` | don't open a browser tab |
58
+ | `--projects-dir DIR` | read logs from somewhere other than `~/.claude/projects` |
59
+
60
+ ## Pricing
61
+
62
+ Rates are `$/MTok` in `PRICES` inside `claude_calc/costlib.py`, as
63
+ `(input, output, cache_read, cache_write_5m, cache_write_1h)`. Messages are
64
+ deduplicated by API message id, and cache writes are billed at the 5-minute
65
+ or 1-hour rate depending on which the message used. Models with no rate are
66
+ skipped and listed at the bottom of the page.
67
+
68
+ ## Develop
69
+
70
+ ```bash
71
+ git clone https://github.com/hibrahem/claude-calc
72
+ cd claude-calc
73
+ python3 -m claude_calc.cli
74
+ ```
75
+
76
+ Releases publish to PyPI from GitHub Actions when a `v*` tag is pushed.
@@ -0,0 +1,57 @@
1
+ # claude-calc
2
+
3
+ See what your Claude Code sessions would cost at pay-per-token API rates.
4
+
5
+ It reads the session logs Claude Code keeps in `~/.claude/projects`, prices
6
+ every API message, and shows the result in a local dashboard: spend over time,
7
+ cost per model split by token type, and projects you can expand down to
8
+ individual sessions.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pipx install claude-calc
14
+ ```
15
+
16
+ or `pip install claude-calc`. Python 3.9 or newer, no other dependencies.
17
+
18
+ ## Use
19
+
20
+ ```bash
21
+ claude-calc
22
+ ```
23
+
24
+ Starts the dashboard on http://localhost:8765 and opens it in your browser.
25
+ Logs are rescanned on every page load and on the Rescan button.
26
+
27
+ ```bash
28
+ claude-calc report
29
+ ```
30
+
31
+ Prints a plain-text summary instead.
32
+
33
+ Options:
34
+
35
+ | Flag | Meaning |
36
+ |---|---|
37
+ | `--port 9000` | serve on a different port |
38
+ | `--no-browser` | don't open a browser tab |
39
+ | `--projects-dir DIR` | read logs from somewhere other than `~/.claude/projects` |
40
+
41
+ ## Pricing
42
+
43
+ Rates are `$/MTok` in `PRICES` inside `claude_calc/costlib.py`, as
44
+ `(input, output, cache_read, cache_write_5m, cache_write_1h)`. Messages are
45
+ deduplicated by API message id, and cache writes are billed at the 5-minute
46
+ or 1-hour rate depending on which the message used. Models with no rate are
47
+ skipped and listed at the bottom of the page.
48
+
49
+ ## Develop
50
+
51
+ ```bash
52
+ git clone https://github.com/hibrahem/claude-calc
53
+ cd claude-calc
54
+ python3 -m claude_calc.cli
55
+ ```
56
+
57
+ Releases publish to PyPI from GitHub Actions when a `v*` tag is pushed.
@@ -0,0 +1,2 @@
1
+ """Price Claude Code session logs and view the spend in a local dashboard."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,46 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+ from . import __version__
6
+
7
+
8
+ def main(argv=None):
9
+ p = argparse.ArgumentParser(
10
+ prog="claude-calc",
11
+ description="Price your Claude Code session logs and view the spend in a local dashboard.",
12
+ )
13
+ p.add_argument("--version", action="version", version=f"claude-calc {__version__}")
14
+ p.add_argument("--projects-dir", metavar="DIR",
15
+ help="folder holding the session logs (default: ~/.claude/projects)")
16
+ sub = p.add_subparsers(dest="cmd")
17
+
18
+ s = sub.add_parser("serve", help="start the dashboard (default)")
19
+ s.add_argument("-p", "--port", type=int, default=8765)
20
+ s.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
21
+
22
+ sub.add_parser("report", help="print a plain-text summary")
23
+
24
+ args = p.parse_args(argv)
25
+ projects_dir = os.path.expanduser(args.projects_dir) if args.projects_dir else None
26
+ if projects_dir and not os.path.isdir(projects_dir):
27
+ p.error(f"no such directory: {projects_dir}")
28
+
29
+ if args.cmd == "report":
30
+ from .report import report
31
+ report(projects_dir)
32
+ return 0
33
+
34
+ from .server import serve
35
+ port = getattr(args, "port", 8765)
36
+ open_browser = not getattr(args, "no_browser", False)
37
+ try:
38
+ serve(port=port, open_browser=open_browser, projects_dir=projects_dir)
39
+ except OSError as e:
40
+ print(f"Could not start on port {port}: {e.strerror}. Try --port <other>.", file=sys.stderr)
41
+ return 1
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ """Scan Claude Code session logs and price every API message.
2
+
3
+ Shared by the CLI (cost.py) and the web portal (serve.py).
4
+ """
5
+ import collections
6
+ import glob
7
+ import json
8
+ import os
9
+
10
+ # $/MTok: (input, output, cache_read, cache_write_5m, cache_write_1h)
11
+ PRICES = {
12
+ "claude-fable-5-1": (10, 50, 0.25, 12.5, 20),
13
+ "claude-fable-5": (10, 50, 1.00, 12.5, 20),
14
+ "claude-opus-5": (5, 25, 0.50, 6.25, 10),
15
+ "claude-opus-4-8": (5, 25, 0.50, 6.25, 10),
16
+ "claude-sonnet-5": (2, 10, 0.20, 2.5, 4),
17
+ "claude-haiku-4-5": (1, 5, 0.10, 1.25, 2),
18
+ }
19
+
20
+ PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
21
+
22
+
23
+ def price(model):
24
+ if model.startswith("claude-haiku-4-5"):
25
+ return PRICES["claude-haiku-4-5"]
26
+ return PRICES.get(model)
27
+
28
+
29
+ def _first_text(content):
30
+ """Return the first plain-text piece of a user message, or ''."""
31
+ if isinstance(content, str):
32
+ return content
33
+ if isinstance(content, list):
34
+ for part in content:
35
+ if isinstance(part, dict) and part.get("type") == "text":
36
+ return part.get("text", "")
37
+ return ""
38
+
39
+
40
+ def _display_project(cwd, dirname):
41
+ home = os.path.expanduser("~")
42
+ if cwd:
43
+ if cwd.startswith(home + "/"):
44
+ return cwd[len(home) + 1:]
45
+ return cwd
46
+ # Fallback: undo the "-Users-name-..." encoding of the directory name.
47
+ encoded_home = home.replace("/", "-")
48
+ if dirname.startswith(encoded_home + "-"):
49
+ return dirname[len(encoded_home) + 1:].replace("-", "/")
50
+ return dirname
51
+
52
+
53
+ def load(projects_dir=PROJECTS_DIR):
54
+ """Return {"rows": [...], "sessions": {...}, "unknown": {...}, "pricing": {...}}.
55
+
56
+ rows: one record per deduplicated assistant API message.
57
+ sessions: sessionId -> {"project", "started", "title"}.
58
+ unknown: model -> total tokens for models with no price.
59
+ """
60
+ seen = {}
61
+ sessions = {}
62
+ pattern = os.path.join(projects_dir, "**", "*.jsonl")
63
+ for path in glob.glob(pattern, recursive=True):
64
+ dirname = os.path.basename(os.path.dirname(path))
65
+ with open(path, errors="replace") as fh:
66
+ for line in fh:
67
+ try:
68
+ d = json.loads(line)
69
+ except ValueError:
70
+ continue
71
+ kind = d.get("type")
72
+ sid = d.get("sessionId")
73
+ if kind == "user" and sid and not d.get("isSidechain"):
74
+ meta = sessions.setdefault(sid, {
75
+ "project": _display_project(d.get("cwd"), dirname),
76
+ "started": d.get("timestamp", ""),
77
+ "title": "",
78
+ })
79
+ if not meta["title"]:
80
+ text = _first_text((d.get("message") or {}).get("content"))
81
+ text = " ".join(text.split())
82
+ if text and not text.startswith("<"):
83
+ meta["title"] = text[:140]
84
+ continue
85
+ if kind != "assistant":
86
+ continue
87
+ m = d.get("message") or {}
88
+ u = m.get("usage")
89
+ mid = m.get("id")
90
+ if not u or not mid:
91
+ continue
92
+ seen[mid] = (d, u, m.get("model", ""), dirname)
93
+
94
+ rows = []
95
+ unknown = collections.Counter()
96
+ for mid, (d, u, model, dirname) in seen.items():
97
+ pr = price(model)
98
+ inp = u.get("input_tokens", 0) or 0
99
+ out = u.get("output_tokens", 0) or 0
100
+ cr = u.get("cache_read_input_tokens", 0) or 0
101
+ cc = u.get("cache_creation") or {}
102
+ w5 = cc.get("ephemeral_5m_input_tokens")
103
+ w1 = cc.get("ephemeral_1h_input_tokens")
104
+ if w5 is None and w1 is None:
105
+ w5 = u.get("cache_creation_input_tokens", 0)
106
+ w1 = 0
107
+ w5 = w5 or 0
108
+ w1 = w1 or 0
109
+ if pr is None:
110
+ unknown[model] += inp + out + cr + w5 + w1
111
+ continue
112
+ sid = d.get("sessionId")
113
+ ts = d.get("timestamp", "")
114
+ meta = sessions.setdefault(sid, {
115
+ "project": _display_project(d.get("cwd"), dirname),
116
+ "started": ts,
117
+ "title": "",
118
+ })
119
+ if ts and (not meta["started"] or ts < meta["started"]):
120
+ meta["started"] = ts
121
+ rows.append({
122
+ "ts": ts,
123
+ "model": model,
124
+ "session": sid,
125
+ "in": inp, "out": out, "cr": cr, "w5": w5, "w1": w1,
126
+ "c_in": inp * pr[0] / 1e6,
127
+ "c_out": out * pr[1] / 1e6,
128
+ "c_cr": cr * pr[2] / 1e6,
129
+ "c_w": (w5 * pr[3] + w1 * pr[4]) / 1e6,
130
+ })
131
+ for r in rows:
132
+ r["cost"] = r["c_in"] + r["c_out"] + r["c_cr"] + r["c_w"]
133
+ for k in ("c_in", "c_out", "c_cr", "c_w", "cost"):
134
+ r[k] = round(r[k], 6)
135
+ rows.sort(key=lambda r: r["ts"])
136
+ # Only keep sessions that have priced rows.
137
+ used = {r["session"] for r in rows}
138
+ sessions = {k: v for k, v in sessions.items() if k in used}
139
+ return {
140
+ "rows": rows,
141
+ "sessions": sessions,
142
+ "unknown": {k: v for k, v in unknown.items() if v},
143
+ "pricing": {k: list(v) for k, v in PRICES.items()},
144
+ }
@@ -0,0 +1,49 @@
1
+ """Plain-text spend report, the same summary the original script printed."""
2
+ import collections
3
+
4
+ from .costlib import load
5
+
6
+
7
+ def report(projects_dir=None):
8
+ data = load(projects_dir) if projects_dir else load()
9
+ rows = data["rows"]
10
+ sessions = data["sessions"]
11
+
12
+ by_model = collections.defaultdict(lambda: collections.Counter())
13
+ by_proj = collections.defaultdict(float)
14
+ by_month = collections.defaultdict(float)
15
+ by_session = collections.defaultdict(float)
16
+ for r in rows:
17
+ c = by_model[r["model"]]
18
+ c["msgs"] += 1
19
+ for k in ("in", "out", "cr", "w5", "w1", "c_in", "c_out", "c_cr", "c_w", "cost"):
20
+ c[k] += r[k]
21
+ by_proj[sessions[r["session"]]["project"]] += r["cost"]
22
+ by_month[r["ts"][:7]] += r["cost"]
23
+ by_session[r["session"]] += r["cost"]
24
+
25
+ total = sum(c["cost"] for c in by_model.values())
26
+ print(f"Deduped API messages priced: {len(rows)}")
27
+ print(f"\nTOTAL: ${total:,.2f}\n")
28
+ if not rows:
29
+ print("No priced messages found.")
30
+ return
31
+ print(f"{'model':28}{'msgs':>7}{'input':>12}{'output':>11}{'cache_rd':>13}{'cw_5m':>11}{'cw_1h':>12}{'cost':>11}")
32
+ for mdl, c in sorted(by_model.items(), key=lambda x: -x[1]['cost']):
33
+ print(f"{mdl:28}{c['msgs']:7d}{c['in']:12,d}{c['out']:11,d}{c['cr']:13,d}{c['w5']:11,d}{c['w1']:12,d}{c['cost']:11,.2f}")
34
+ print("\nCost split by token type:")
35
+ for k, lab in [("c_in", "uncached input"), ("c_out", "output"), ("c_cr", "cache reads"), ("c_w", "cache writes")]:
36
+ v = sum(c[k] for c in by_model.values())
37
+ print(f" {lab:16}${v:9,.2f} ({v/total*100:4.1f}%)")
38
+ print("\nBy month:")
39
+ for k in sorted(by_month):
40
+ print(f" {k} ${by_month[k]:9,.2f}")
41
+ print("\nBy project:")
42
+ for k, v in sorted(by_proj.items(), key=lambda x: -x[1]):
43
+ print(f" ${v:9,.2f} {k}")
44
+ print("\nTop 10 sessions:")
45
+ for k, v in sorted(by_session.items(), key=lambda x: -x[1])[:10]:
46
+ s = sessions[k]
47
+ print(f" ${v:8,.2f} {s['started'][:10]} {k[:8]} {s['project']}")
48
+ if data["unknown"]:
49
+ print("\nSkipped (unpriced) models, total tokens:", data["unknown"])
@@ -0,0 +1,58 @@
1
+ """Local web portal for Claude Code spend."""
2
+ import json
3
+ import os
4
+ import threading
5
+ import time
6
+ import webbrowser
7
+ from functools import partial
8
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
9
+
10
+ from .costlib import load
11
+
12
+ STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
13
+
14
+
15
+ class Handler(SimpleHTTPRequestHandler):
16
+ def __init__(self, *a, projects_dir=None, **kw):
17
+ self.projects_dir = projects_dir
18
+ super().__init__(*a, directory=STATIC, **kw)
19
+
20
+ def do_GET(self):
21
+ if self.path.split("?")[0] == "/api/rows":
22
+ t0 = time.time()
23
+ data = load(self.projects_dir) if self.projects_dir else load()
24
+ data["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
25
+ data["scan_ms"] = int((time.time() - t0) * 1000)
26
+ body = json.dumps(data).encode()
27
+ self.send_response(200)
28
+ self.send_header("Content-Type", "application/json")
29
+ self.send_header("Content-Length", str(len(body)))
30
+ self.end_headers()
31
+ self.wfile.write(body)
32
+ return
33
+ if self.path == "/":
34
+ self.path = "/index.html"
35
+ return super().do_GET()
36
+
37
+ def end_headers(self):
38
+ self.send_header("Cache-Control", "no-store")
39
+ super().end_headers()
40
+
41
+ def log_message(self, fmt, *args):
42
+ if "/api/" in (args[0] if args else ""):
43
+ super().log_message(fmt, *args)
44
+
45
+
46
+ def serve(port=8765, open_browser=True, projects_dir=None):
47
+ handler = partial(Handler, projects_dir=projects_dir)
48
+ srv = ThreadingHTTPServer(("127.0.0.1", port), handler)
49
+ url = f"http://localhost:{port}"
50
+ print(f"Claude Code spend portal: {url} (Ctrl+C to stop)")
51
+ if open_browser:
52
+ threading.Timer(0.5, webbrowser.open, args=(url,)).start()
53
+ try:
54
+ srv.serve_forever()
55
+ except KeyboardInterrupt:
56
+ pass
57
+ finally:
58
+ srv.server_close()