havrin 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.
- havrin-0.1.0/.gitignore +9 -0
- havrin-0.1.0/PKG-INFO +38 -0
- havrin-0.1.0/havrin/LICENSE +21 -0
- havrin-0.1.0/havrin/README.md +23 -0
- havrin-0.1.0/havrin/__init__.py +2 -0
- havrin-0.1.0/havrin/__main__.py +30 -0
- havrin-0.1.0/hybrid/meter.py +171 -0
- havrin-0.1.0/pyproject.toml +40 -0
havrin-0.1.0/.gitignore
ADDED
havrin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: havrin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See where your Claude Code usage goes, from your own local session logs
|
|
5
|
+
Project-URL: Homepage, https://havrin.dev
|
|
6
|
+
Author: Gavin Schiller
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: havrin/LICENSE
|
|
9
|
+
Keywords: claude,claude-code,cost,llm,usage
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# havrin
|
|
17
|
+
|
|
18
|
+
See where your Claude Code usage goes, from the session logs Claude Code already keeps on your machine.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pipx install havrin # or: pip install havrin
|
|
22
|
+
havrin meter --days 30 --what-if
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The meter reads the JSONL transcripts in `~/.claude/projects` and prints:
|
|
26
|
+
|
|
27
|
+
- tokens by kind (input, cache writes, cache reads, output) and each kind's cost-weighted share;
|
|
28
|
+
- which tools filled the context, in characters;
|
|
29
|
+
- with `--what-if`: the share of cost you would have saved with subagents on Sonnet, with the main context capped
|
|
30
|
+
(150k tokens by default), and with both. These are estimates replayed from your own logs, not promises.
|
|
31
|
+
|
|
32
|
+
**Privacy:** it only reads local files, sends nothing anywhere, and prints counts, never the content of your sessions.
|
|
33
|
+
|
|
34
|
+
Options: `--days N` (default 7), `--sessions` (one line per transcript), `--dir PATH`, `--sonnet-ratio`, `--cap`.
|
|
35
|
+
Python 3.10+, no dependencies.
|
|
36
|
+
|
|
37
|
+
Havrin is building the rest: routing routine Claude Code steps to cheaper models and your own machine, with your
|
|
38
|
+
tests checking every result. Join the beta at [havrin.dev](https://havrin.dev).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gavin Schiller
|
|
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,23 @@
|
|
|
1
|
+
# havrin
|
|
2
|
+
|
|
3
|
+
See where your Claude Code usage goes, from the session logs Claude Code already keeps on your machine.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pipx install havrin # or: pip install havrin
|
|
7
|
+
havrin meter --days 30 --what-if
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The meter reads the JSONL transcripts in `~/.claude/projects` and prints:
|
|
11
|
+
|
|
12
|
+
- tokens by kind (input, cache writes, cache reads, output) and each kind's cost-weighted share;
|
|
13
|
+
- which tools filled the context, in characters;
|
|
14
|
+
- with `--what-if`: the share of cost you would have saved with subagents on Sonnet, with the main context capped
|
|
15
|
+
(150k tokens by default), and with both. These are estimates replayed from your own logs, not promises.
|
|
16
|
+
|
|
17
|
+
**Privacy:** it only reads local files, sends nothing anywhere, and prints counts, never the content of your sessions.
|
|
18
|
+
|
|
19
|
+
Options: `--days N` (default 7), `--sessions` (one line per transcript), `--dir PATH`, `--sonnet-ratio`, `--cap`.
|
|
20
|
+
Python 3.10+, no dependencies.
|
|
21
|
+
|
|
22
|
+
Havrin is building the rest: routing routine Claude Code steps to cheaper models and your own machine, with your
|
|
23
|
+
tests checking every result. Join the beta at [havrin.dev](https://havrin.dev).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The `havrin` command. The meter itself is hybrid/meter.py, copied in as havrin/meter.py at build time
|
|
2
|
+
(see pyproject.toml), so the private repo keeps one copy of it."""
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
|
|
7
|
+
USAGE = """usage: havrin meter [--days N] [--what-if] [--sessions] [--dir PATH]
|
|
8
|
+
havrin --version
|
|
9
|
+
|
|
10
|
+
meter where your Claude Code usage went, read from local session logs (nothing is sent anywhere)"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv=None):
|
|
14
|
+
args = sys.argv[1:] if argv is None else argv
|
|
15
|
+
if args[:1] == ["meter"]:
|
|
16
|
+
from . import meter
|
|
17
|
+
meter.main(args[1:], prog="havrin meter")
|
|
18
|
+
return 0
|
|
19
|
+
if args[:1] in (["--version"], ["-V"]):
|
|
20
|
+
print(f"havrin {__version__}")
|
|
21
|
+
return 0
|
|
22
|
+
if args[:1] in ([], ["-h"], ["--help"]):
|
|
23
|
+
print(USAGE)
|
|
24
|
+
return 0
|
|
25
|
+
print(USAGE, file=sys.stderr)
|
|
26
|
+
return 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
sys.exit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Savings meter, step 1: where a coding agent's usage goes, read from Claude Code's own session logs.
|
|
2
|
+
|
|
3
|
+
Usage: python3 -m hybrid.meter [--days 7] [--dir ~/.claude/projects]
|
|
4
|
+
|
|
5
|
+
Reads the local JSONL transcripts Claude Code writes (one file per session) and prints, per session and in total:
|
|
6
|
+
- tokens by kind;
|
|
7
|
+
- a cost-weighted share of each kind;
|
|
8
|
+
- characters each tool's results added to the context;
|
|
9
|
+
- characters written as text or tool input.
|
|
10
|
+
|
|
11
|
+
Only local files are read, nothing is sent anywhere, and only counts are printed, never content.
|
|
12
|
+
Savings need a comparison (helpers on vs. off); this module only measures.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
KINDS = ("input", "cache_write_5m", "cache_write_1h", "cache_read", "output")
|
|
21
|
+
# shortcut: Anthropic list-price ratios to new input as of 2026-10 (5-minute cache writes 1.25x, 1-hour writes 2x,
|
|
22
|
+
# reads 0.1x, output 5x); verify against current pricing, and use per-model prices if dollars are needed.
|
|
23
|
+
WEIGHTS = {"input": 1.0, "cache_write_5m": 1.25, "cache_write_1h": 2.0, "cache_read": 0.1, "output": 5.0}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tokens(usage):
|
|
27
|
+
split = usage.get("cache_creation") or {}
|
|
28
|
+
write_1h = split.get("ephemeral_1h_input_tokens", 0)
|
|
29
|
+
return Counter({"input": usage.get("input_tokens", 0),
|
|
30
|
+
"cache_write_5m": usage.get("cache_creation_input_tokens", 0) - write_1h,
|
|
31
|
+
"cache_write_1h": write_1h,
|
|
32
|
+
"cache_read": usage.get("cache_read_input_tokens", 0),
|
|
33
|
+
"output": usage.get("output_tokens", 0)})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _text(content):
|
|
37
|
+
if isinstance(content, str):
|
|
38
|
+
return content
|
|
39
|
+
return "".join(x.get("text", "") for x in content if isinstance(x, dict)) if isinstance(content, list) else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def session(path, seen=None):
|
|
43
|
+
"""{"requests", "tokens", "models", "read", "written", "start", "end"} for one transcript. A request's usage is
|
|
44
|
+
logged once per content block, so it is counted once per requestId (the last entry wins). Requests already in
|
|
45
|
+
`seen` (a set shared across files: resumed sessions repeat earlier requests) are skipped and new ones added."""
|
|
46
|
+
seen = set() if seen is None else seen
|
|
47
|
+
usage, names, read, written, stamps = {}, {}, Counter(), Counter(), []
|
|
48
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
49
|
+
for line in f:
|
|
50
|
+
try:
|
|
51
|
+
e = json.loads(line)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
if not isinstance(e, dict) or not isinstance(e.get("message"), dict):
|
|
55
|
+
continue
|
|
56
|
+
if e.get("timestamp"):
|
|
57
|
+
stamps.append(e["timestamp"])
|
|
58
|
+
m, kind = e["message"], e.get("type")
|
|
59
|
+
content = m.get("content") if isinstance(m.get("content"), list) else []
|
|
60
|
+
if kind == "assistant":
|
|
61
|
+
rid = e.get("requestId") or e.get("uuid")
|
|
62
|
+
if isinstance(m.get("usage"), dict) and rid not in seen:
|
|
63
|
+
usage[rid] = (m.get("model") or "?", m["usage"])
|
|
64
|
+
for b in content:
|
|
65
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
66
|
+
names[b.get("id")] = b.get("name", "?")
|
|
67
|
+
written[b.get("name", "?")] += len(json.dumps(b.get("input", {})))
|
|
68
|
+
elif isinstance(b, dict) and b.get("type") == "text":
|
|
69
|
+
written["text"] += len(b.get("text", ""))
|
|
70
|
+
elif kind == "user":
|
|
71
|
+
for b in content:
|
|
72
|
+
if isinstance(b, dict) and b.get("type") == "tool_result":
|
|
73
|
+
read[names.get(b.get("tool_use_id"), "?")] += len(_text(b.get("content")))
|
|
74
|
+
seen.update(usage)
|
|
75
|
+
models = Counter()
|
|
76
|
+
for model, u in usage.values():
|
|
77
|
+
models[model] += sum(weighted(_tokens(u)).values())
|
|
78
|
+
return {"requests": len(usage), "tokens": sum((_tokens(u) for _, u in usage.values()), Counter()),
|
|
79
|
+
"models": models, "reads": [u.get("cache_read_input_tokens", 0) for _, u in usage.values()],
|
|
80
|
+
"read": read, "written": written, "start": min(stamps, default=""),
|
|
81
|
+
"end": max(stamps, default="")}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sessions(root, days):
|
|
85
|
+
"""(path, session) for transcripts under `root` modified in the last `days` days, oldest first."""
|
|
86
|
+
since = time.time() - days * 86400
|
|
87
|
+
paths = sorted((p for p in Path(root).expanduser().rglob("*.jsonl") if p.stat().st_mtime >= since),
|
|
88
|
+
key=lambda p: p.stat().st_mtime)
|
|
89
|
+
seen = set()
|
|
90
|
+
return [(p, s) for p in paths if (s := session(p, seen))["requests"]]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _shares(counter, top=6):
|
|
94
|
+
total = sum(counter.values())
|
|
95
|
+
if not total:
|
|
96
|
+
return "none"
|
|
97
|
+
shown = [(k, v) for k, v in counter.most_common(top) if v]
|
|
98
|
+
rest = total - sum(v for _, v in shown)
|
|
99
|
+
return ", ".join(f"{k} {v / total:.0%}" for k, v in shown) + (f", other {rest / total:.0%}" if rest else "")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def weighted(tokens):
|
|
103
|
+
return Counter({k: tokens[k] * WEIGHTS[k] for k in KINDS})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def report(found, per_session=False):
|
|
107
|
+
lines, tokens, models, read, written = [], Counter(), Counter(), Counter(), Counter()
|
|
108
|
+
for path, s in found:
|
|
109
|
+
w = weighted(s["tokens"])
|
|
110
|
+
if per_session:
|
|
111
|
+
lines.append(f"{s['start'][:16]} {path.stem[:8]}: {s['requests']} requests, "
|
|
112
|
+
f"{sum(w.values()) / 1e6:.2f}M input-equivalent tokens ({_shares(w)})")
|
|
113
|
+
tokens.update(s["tokens"]), models.update(s["models"]), read.update(s["read"]), written.update(s["written"])
|
|
114
|
+
if not found:
|
|
115
|
+
return ["no Claude Code sessions with usage found"]
|
|
116
|
+
requests = sum(s["requests"] for _, s in found)
|
|
117
|
+
context = tokens["input"] + tokens["cache_write_5m"] + tokens["cache_write_1h"] + tokens["cache_read"]
|
|
118
|
+
subagents = sum(p.stem.startswith("agent-") for p, _ in found)
|
|
119
|
+
sub_cost = sum(sum(s["models"].values()) for p, s in found if p.stem.startswith("agent-"))
|
|
120
|
+
all_cost = sum(sum(s["models"].values()) for _, s in found)
|
|
121
|
+
return lines + ([""] if lines else []) + [
|
|
122
|
+
f"total: {len(found)} transcripts ({subagents} subagent), {requests} requests, "
|
|
123
|
+
f"{sum(weighted(tokens).values()) / 1e6:.1f}M input-equivalent tokens",
|
|
124
|
+
f" average context per request: {context / requests:,.0f} tokens",
|
|
125
|
+
f" cost-weighted, by kind: {_shares(weighted(tokens))}",
|
|
126
|
+
f" cost-weighted, by model: {_shares(models)}",
|
|
127
|
+
f" subagents' share of cost: {sub_cost / all_cost if all_cost else 0:.0%}",
|
|
128
|
+
f" tokens: " + ", ".join(f"{k} {tokens[k]:,}" for k in KINDS),
|
|
129
|
+
f" chars read, by tool: {_shares(read)} ({sum(read.values()):,} chars)",
|
|
130
|
+
f" chars written, by kind: {_shares(written)} ({sum(written.values()):,} chars)"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def what_if(found, sonnet_ratio=0.6, cap=150_000):
|
|
134
|
+
"""Replay of the measured usage under two changes, in cost-weighted tokens:
|
|
135
|
+
- subagents that ran on Opus repriced at `sonnet_ratio` of Opus (list-price ratio; check current prices);
|
|
136
|
+
- main conversations never re-reading more than `cap` tokens of context per request (as if cleared or compacted
|
|
137
|
+
there). shortcut: an upper bound; work lost by clearing would partly be re-read, which only a live test shows.
|
|
138
|
+
"""
|
|
139
|
+
total = sum(sum(s["models"].values()) for _, s in found)
|
|
140
|
+
if not total:
|
|
141
|
+
return ["no usage to replay"]
|
|
142
|
+
agent = [s for p, s in found if p.stem.startswith("agent-")]
|
|
143
|
+
main_ = [s for p, s in found if not p.stem.startswith("agent-")]
|
|
144
|
+
opus_sub = sum(v for s in agent for m, v in s["models"].items() if "opus" in m)
|
|
145
|
+
sub_saving = opus_sub * (1 - sonnet_ratio)
|
|
146
|
+
cap_saving = sum(max(0, r - cap) * WEIGHTS["cache_read"] for s in main_ for r in s["reads"])
|
|
147
|
+
lines = [f"what-if replay of {total / 1e6:.1f}M input-equivalent tokens:",
|
|
148
|
+
f" subagents on Sonnet (Opus subagent cost {opus_sub / total:.0%} of total, Sonnet at "
|
|
149
|
+
f"{sonnet_ratio:.0%} of Opus price): saves {sub_saving / total:.0%}",
|
|
150
|
+
f" main context capped at {cap // 1000}k tokens (upper bound): saves {cap_saving / total:.0%}",
|
|
151
|
+
f" both: saves {(sub_saving + cap_saving) / total:.0%}"]
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv=None, prog="python3 -m hybrid.meter"):
|
|
156
|
+
p = argparse.ArgumentParser(prog=prog, description=__doc__.split("\n")[0])
|
|
157
|
+
p.add_argument("--days", type=float, default=7, help="sessions modified in the last N days (default 7)")
|
|
158
|
+
p.add_argument("--dir", default="~/.claude/projects", help="Claude Code transcript directory")
|
|
159
|
+
p.add_argument("--sessions", action="store_true", help="also print one line per transcript")
|
|
160
|
+
p.add_argument("--what-if", action="store_true", help="also replay the usage with subagents on Sonnet and a "
|
|
161
|
+
"context cap (see what_if)")
|
|
162
|
+
p.add_argument("--sonnet-ratio", type=float, default=0.6, help="Sonnet price as a share of Opus (default 0.6)")
|
|
163
|
+
p.add_argument("--cap", type=int, default=150_000, help="context cap in tokens for --what-if (default 150000)")
|
|
164
|
+
args = p.parse_args(argv)
|
|
165
|
+
found = sessions(args.dir, args.days)
|
|
166
|
+
print("\n".join(report(found, args.sessions) + (["", *what_if(found, args.sonnet_ratio, args.cap)]
|
|
167
|
+
if args.what_if else [])))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Builds the public `havrin` package: the meter only (MIT). The rest of this repo is private and must never
|
|
2
|
+
# enter the wheel or sdist, so both targets list exactly what they ship.
|
|
3
|
+
[build-system]
|
|
4
|
+
requires = ["hatchling>=1.26"]
|
|
5
|
+
build-backend = "hatchling.build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "havrin"
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "See where your Claude Code usage goes, from your own local session logs"
|
|
11
|
+
readme = "havrin/README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["havrin/LICENSE"]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
authors = [{ name = "Gavin Schiller" }]
|
|
16
|
+
keywords = ["claude", "claude-code", "llm", "usage", "cost"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://havrin.dev"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
havrin = "havrin.__main__:main"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.version]
|
|
30
|
+
path = "havrin/__init__.py"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["havrin"]
|
|
34
|
+
exclude = ["havrin/README.md", "havrin/LICENSE"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
37
|
+
"hybrid/meter.py" = "havrin/meter.py"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
only-include = ["havrin", "hybrid/meter.py", "pyproject.toml"]
|