contexttax 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.
- contexttax-0.1.0/LICENSE +21 -0
- contexttax-0.1.0/PKG-INFO +78 -0
- contexttax-0.1.0/README.md +63 -0
- contexttax-0.1.0/contexttax.egg-info/PKG-INFO +78 -0
- contexttax-0.1.0/contexttax.egg-info/SOURCES.txt +10 -0
- contexttax-0.1.0/contexttax.egg-info/dependency_links.txt +1 -0
- contexttax-0.1.0/contexttax.egg-info/entry_points.txt +2 -0
- contexttax-0.1.0/contexttax.egg-info/top_level.txt +1 -0
- contexttax-0.1.0/contexttax.py +275 -0
- contexttax-0.1.0/pyproject.toml +23 -0
- contexttax-0.1.0/setup.cfg +4 -0
- contexttax-0.1.0/tests/test_contexttax.py +63 -0
contexttax-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Danielsson
|
|
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,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contexttax
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Where your Claude Code usage actually goes. Reads local logs only.
|
|
5
|
+
Author: Adam Danielsson
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/theadamdanielsson/contexttax
|
|
8
|
+
Keywords: claude-code,usage,limits,tokens,context
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# contexttax
|
|
17
|
+
|
|
18
|
+
Where your Claude Code usage actually goes.
|
|
19
|
+
|
|
20
|
+
Most of it isn't loops or re-reads. It's long conversations. Every turn sends the whole conversation again, so a session at 500k tokens pays for all of it on every message.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uvx contexttax
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No install with uv. Or, with plain Python 3:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
curl -fsSL https://raw.githubusercontent.com/theadamdanielsson/contexttax/main/contexttax.py | python3 -
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What it shows
|
|
33
|
+
|
|
34
|
+
My last 27 days:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
contexttax · Claude Code · last 27 days · 316 sessions · 8,188 model calls
|
|
38
|
+
|
|
39
|
+
76% of your usage went to turns where the conversation was already over 200k tokens.
|
|
40
|
+
Your biggest 10% of sessions used 90%. One session alone used 14%.
|
|
41
|
+
A turn at 500k+ context costs 5.0x a turn at 50-100k.
|
|
42
|
+
Median turn: 255k of context. One in ten: over 604k.
|
|
43
|
+
|
|
44
|
+
Starting fresh with a short summary at 200k would have saved about 24%.
|
|
45
|
+
|
|
46
|
+
What people usually blame:
|
|
47
|
+
re-reading the same file 43 of 792 reads
|
|
48
|
+
loops (same call 3 times) 1
|
|
49
|
+
huge tool outputs 1%
|
|
50
|
+
subagents 5%
|
|
51
|
+
cache rebuilt after a break 4%
|
|
52
|
+
|
|
53
|
+
Where it goes: cache reads 70%, cache writes 20%, output 10%
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Private by design
|
|
57
|
+
|
|
58
|
+
It reads the logs Claude Code already keeps in `~/.claude/projects`. It prints numbers only. It sends nothing anywhere and writes nothing. One file, standard library only, so you can read all of it first.
|
|
59
|
+
|
|
60
|
+
## How it counts
|
|
61
|
+
|
|
62
|
+
Usage is weighted by API prices, the closest public stand-in for how plan limits are counted. Per model: Opus 5, Sonnet 3, Haiku 1. Within a model: input 1, cache write 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5. Plan limits aren't published, so treat the shares as close, not exact.
|
|
63
|
+
|
|
64
|
+
The fresh-start saving assumes each turn past 200k would instead carry a 20k summary. It ignores what a summary leaves out.
|
|
65
|
+
|
|
66
|
+
## Options
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
contexttax --days 7 # last week
|
|
70
|
+
contexttax --days 0 # everything in the logs
|
|
71
|
+
contexttax --json # the numbers as JSON
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Share yours
|
|
75
|
+
|
|
76
|
+
If your numbers look different from mine, I'd like to see them. Open an issue with the output.
|
|
77
|
+
|
|
78
|
+
MIT licence.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# contexttax
|
|
2
|
+
|
|
3
|
+
Where your Claude Code usage actually goes.
|
|
4
|
+
|
|
5
|
+
Most of it isn't loops or re-reads. It's long conversations. Every turn sends the whole conversation again, so a session at 500k tokens pays for all of it on every message.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
uvx contexttax
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No install with uv. Or, with plain Python 3:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
curl -fsSL https://raw.githubusercontent.com/theadamdanielsson/contexttax/main/contexttax.py | python3 -
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it shows
|
|
18
|
+
|
|
19
|
+
My last 27 days:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
contexttax · Claude Code · last 27 days · 316 sessions · 8,188 model calls
|
|
23
|
+
|
|
24
|
+
76% of your usage went to turns where the conversation was already over 200k tokens.
|
|
25
|
+
Your biggest 10% of sessions used 90%. One session alone used 14%.
|
|
26
|
+
A turn at 500k+ context costs 5.0x a turn at 50-100k.
|
|
27
|
+
Median turn: 255k of context. One in ten: over 604k.
|
|
28
|
+
|
|
29
|
+
Starting fresh with a short summary at 200k would have saved about 24%.
|
|
30
|
+
|
|
31
|
+
What people usually blame:
|
|
32
|
+
re-reading the same file 43 of 792 reads
|
|
33
|
+
loops (same call 3 times) 1
|
|
34
|
+
huge tool outputs 1%
|
|
35
|
+
subagents 5%
|
|
36
|
+
cache rebuilt after a break 4%
|
|
37
|
+
|
|
38
|
+
Where it goes: cache reads 70%, cache writes 20%, output 10%
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Private by design
|
|
42
|
+
|
|
43
|
+
It reads the logs Claude Code already keeps in `~/.claude/projects`. It prints numbers only. It sends nothing anywhere and writes nothing. One file, standard library only, so you can read all of it first.
|
|
44
|
+
|
|
45
|
+
## How it counts
|
|
46
|
+
|
|
47
|
+
Usage is weighted by API prices, the closest public stand-in for how plan limits are counted. Per model: Opus 5, Sonnet 3, Haiku 1. Within a model: input 1, cache write 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5. Plan limits aren't published, so treat the shares as close, not exact.
|
|
48
|
+
|
|
49
|
+
The fresh-start saving assumes each turn past 200k would instead carry a 20k summary. It ignores what a summary leaves out.
|
|
50
|
+
|
|
51
|
+
## Options
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
contexttax --days 7 # last week
|
|
55
|
+
contexttax --days 0 # everything in the logs
|
|
56
|
+
contexttax --json # the numbers as JSON
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Share yours
|
|
60
|
+
|
|
61
|
+
If your numbers look different from mine, I'd like to see them. Open an issue with the output.
|
|
62
|
+
|
|
63
|
+
MIT licence.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contexttax
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Where your Claude Code usage actually goes. Reads local logs only.
|
|
5
|
+
Author: Adam Danielsson
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/theadamdanielsson/contexttax
|
|
8
|
+
Keywords: claude-code,usage,limits,tokens,context
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# contexttax
|
|
17
|
+
|
|
18
|
+
Where your Claude Code usage actually goes.
|
|
19
|
+
|
|
20
|
+
Most of it isn't loops or re-reads. It's long conversations. Every turn sends the whole conversation again, so a session at 500k tokens pays for all of it on every message.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uvx contexttax
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No install with uv. Or, with plain Python 3:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
curl -fsSL https://raw.githubusercontent.com/theadamdanielsson/contexttax/main/contexttax.py | python3 -
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What it shows
|
|
33
|
+
|
|
34
|
+
My last 27 days:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
contexttax · Claude Code · last 27 days · 316 sessions · 8,188 model calls
|
|
38
|
+
|
|
39
|
+
76% of your usage went to turns where the conversation was already over 200k tokens.
|
|
40
|
+
Your biggest 10% of sessions used 90%. One session alone used 14%.
|
|
41
|
+
A turn at 500k+ context costs 5.0x a turn at 50-100k.
|
|
42
|
+
Median turn: 255k of context. One in ten: over 604k.
|
|
43
|
+
|
|
44
|
+
Starting fresh with a short summary at 200k would have saved about 24%.
|
|
45
|
+
|
|
46
|
+
What people usually blame:
|
|
47
|
+
re-reading the same file 43 of 792 reads
|
|
48
|
+
loops (same call 3 times) 1
|
|
49
|
+
huge tool outputs 1%
|
|
50
|
+
subagents 5%
|
|
51
|
+
cache rebuilt after a break 4%
|
|
52
|
+
|
|
53
|
+
Where it goes: cache reads 70%, cache writes 20%, output 10%
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Private by design
|
|
57
|
+
|
|
58
|
+
It reads the logs Claude Code already keeps in `~/.claude/projects`. It prints numbers only. It sends nothing anywhere and writes nothing. One file, standard library only, so you can read all of it first.
|
|
59
|
+
|
|
60
|
+
## How it counts
|
|
61
|
+
|
|
62
|
+
Usage is weighted by API prices, the closest public stand-in for how plan limits are counted. Per model: Opus 5, Sonnet 3, Haiku 1. Within a model: input 1, cache write 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5. Plan limits aren't published, so treat the shares as close, not exact.
|
|
63
|
+
|
|
64
|
+
The fresh-start saving assumes each turn past 200k would instead carry a 20k summary. It ignores what a summary leaves out.
|
|
65
|
+
|
|
66
|
+
## Options
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
contexttax --days 7 # last week
|
|
70
|
+
contexttax --days 0 # everything in the logs
|
|
71
|
+
contexttax --json # the numbers as JSON
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Share yours
|
|
75
|
+
|
|
76
|
+
If your numbers look different from mine, I'd like to see them. Open an issue with the output.
|
|
77
|
+
|
|
78
|
+
MIT licence.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
contexttax.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
contexttax.egg-info/PKG-INFO
|
|
6
|
+
contexttax.egg-info/SOURCES.txt
|
|
7
|
+
contexttax.egg-info/dependency_links.txt
|
|
8
|
+
contexttax.egg-info/entry_points.txt
|
|
9
|
+
contexttax.egg-info/top_level.txt
|
|
10
|
+
tests/test_contexttax.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
contexttax
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""contexttax: where your Claude Code usage actually goes.
|
|
3
|
+
|
|
4
|
+
Reads the session logs Claude Code keeps on this computer (~/.claude/projects) and shows
|
|
5
|
+
how much of your usage went to long conversations, compared with the causes people
|
|
6
|
+
usually blame. Prints numbers only. Reads no conversation text into the output, sends
|
|
7
|
+
nothing anywhere, writes nothing.
|
|
8
|
+
|
|
9
|
+
Usage is weighted by API prices, the closest public stand-in for how plan limits are
|
|
10
|
+
counted: per model, Opus 5, Sonnet 3, Haiku 1; within a model, input 1, cache write
|
|
11
|
+
1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from datetime import datetime, timedelta, timezone
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
BIG_CONTEXT = 200_000 # where "long" starts in the report
|
|
27
|
+
SUMMARY = 20_000 # tokens a fresh start would carry over
|
|
28
|
+
BIG_RESULT_CHARS = 40_000
|
|
29
|
+
CHARS_PER_TOKEN = 3.6
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def model_weight(model: str) -> float:
|
|
33
|
+
m = model.lower()
|
|
34
|
+
return 5.0 if "opus" in m else 1.0 if "haiku" in m else 3.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_time(s: str | None) -> float | None:
|
|
38
|
+
if not s:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def result_chars(content) -> int:
|
|
47
|
+
if isinstance(content, str):
|
|
48
|
+
return len(content)
|
|
49
|
+
if isinstance(content, list):
|
|
50
|
+
n = 0
|
|
51
|
+
for c in content:
|
|
52
|
+
if isinstance(c, dict) and c.get("type") == "text":
|
|
53
|
+
n += len(c.get("text", ""))
|
|
54
|
+
elif isinstance(c, dict) and c.get("type") == "image":
|
|
55
|
+
n += 6000
|
|
56
|
+
return n
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Turn:
|
|
61
|
+
__slots__ = ("t", "model", "side", "context", "cost", "write_cost", "parts", "write_1h")
|
|
62
|
+
|
|
63
|
+
def __init__(self, t, model, side, usage):
|
|
64
|
+
self.t, self.model, self.side = t, model, side
|
|
65
|
+
cc = usage.get("cache_creation") or {}
|
|
66
|
+
w1h = cc.get("ephemeral_1h_input_tokens", 0) or 0
|
|
67
|
+
w5m = cc.get("ephemeral_5m_input_tokens", 0) or 0
|
|
68
|
+
written = usage.get("cache_creation_input_tokens", 0) or 0
|
|
69
|
+
if not w1h and not w5m:
|
|
70
|
+
w5m = written
|
|
71
|
+
read = usage.get("cache_read_input_tokens", 0) or 0
|
|
72
|
+
fresh = usage.get("input_tokens", 0) or 0
|
|
73
|
+
out = usage.get("output_tokens", 0) or 0
|
|
74
|
+
mw = model_weight(model)
|
|
75
|
+
self.parts = {"cache reads": read * 0.1 * mw, "cache writes": (w5m * 1.25 + w1h * 2) * mw, "output": out * 5 * mw, "fresh input": fresh * mw}
|
|
76
|
+
self.cost = sum(self.parts.values())
|
|
77
|
+
self.write_cost = self.parts["cache writes"]
|
|
78
|
+
self.write_1h = bool(w1h)
|
|
79
|
+
self.context = fresh + written + read
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_session(path: str):
|
|
83
|
+
"""Model calls (deduplicated), tool calls and tool result sizes from one log."""
|
|
84
|
+
turns, uses, results, seen = [], {}, {}, set()
|
|
85
|
+
with open(path, errors="ignore") as fh:
|
|
86
|
+
for line in fh:
|
|
87
|
+
try:
|
|
88
|
+
d = json.loads(line)
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
m = d.get("message")
|
|
92
|
+
if not isinstance(m, dict):
|
|
93
|
+
continue
|
|
94
|
+
if d.get("type") == "assistant" and m.get("usage"):
|
|
95
|
+
key = m.get("id") or d.get("requestId")
|
|
96
|
+
model = m.get("model") or ""
|
|
97
|
+
if key not in seen and not model.startswith("<"):
|
|
98
|
+
seen.add(key)
|
|
99
|
+
turns.append(Turn(parse_time(d.get("timestamp")), model, bool(d.get("isSidechain")), m["usage"]))
|
|
100
|
+
for c in m.get("content") or []:
|
|
101
|
+
if isinstance(c, dict) and c.get("type") == "tool_use":
|
|
102
|
+
uses[c.get("id")] = (c.get("name"), c.get("input") or {}, bool(d.get("isSidechain")))
|
|
103
|
+
elif d.get("type") == "user" and isinstance(m.get("content"), list):
|
|
104
|
+
for c in m["content"]:
|
|
105
|
+
if isinstance(c, dict) and c.get("type") == "tool_result":
|
|
106
|
+
results[c.get("tool_use_id")] = (parse_time(d.get("timestamp")), result_chars(c.get("content")))
|
|
107
|
+
turns.sort(key=lambda x: x.t or 0)
|
|
108
|
+
return turns, uses, results
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def analyse(root: str, days: int | None):
|
|
112
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp() if days else None
|
|
113
|
+
files = glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)
|
|
114
|
+
if since:
|
|
115
|
+
files = [f for f in files if os.path.getmtime(f) >= since]
|
|
116
|
+
total = 0.0
|
|
117
|
+
parts = Counter()
|
|
118
|
+
by_model = Counter()
|
|
119
|
+
session_costs = []
|
|
120
|
+
big_context = 0.0
|
|
121
|
+
fresh_start_saving = 0.0
|
|
122
|
+
small_turns, big_turns = [], []
|
|
123
|
+
contexts = []
|
|
124
|
+
subagents = idle_rebuilds = 0.0
|
|
125
|
+
reads = rereads = loops = 0
|
|
126
|
+
big_result_carry = 0.0
|
|
127
|
+
turn_count = 0
|
|
128
|
+
first = last = None
|
|
129
|
+
|
|
130
|
+
for f in files:
|
|
131
|
+
turns, uses, results = read_session(f)
|
|
132
|
+
if since:
|
|
133
|
+
turns = [t for t in turns if t.t and t.t >= since]
|
|
134
|
+
if not turns:
|
|
135
|
+
continue
|
|
136
|
+
s_cost, prev = 0.0, None
|
|
137
|
+
for t in turns:
|
|
138
|
+
total += t.cost
|
|
139
|
+
s_cost += t.cost
|
|
140
|
+
turn_count += 1
|
|
141
|
+
by_model[t.model] += t.cost
|
|
142
|
+
for k, v in t.parts.items():
|
|
143
|
+
parts[k] += v
|
|
144
|
+
if t.t:
|
|
145
|
+
first = t.t if first is None else min(first, t.t)
|
|
146
|
+
last = t.t if last is None else max(last, t.t)
|
|
147
|
+
if t.side:
|
|
148
|
+
subagents += t.cost
|
|
149
|
+
continue
|
|
150
|
+
contexts.append(t.context)
|
|
151
|
+
if t.context >= BIG_CONTEXT:
|
|
152
|
+
big_context += t.cost
|
|
153
|
+
# Started over with a summary at BIG_CONTEXT, this turn would carry
|
|
154
|
+
# (BIG_CONTEXT - SUMMARY) fewer tokens, re-read from cache.
|
|
155
|
+
fresh_start_saving += (BIG_CONTEXT - SUMMARY) * 0.1 * model_weight(t.model)
|
|
156
|
+
if 50_000 <= t.context < 100_000:
|
|
157
|
+
small_turns.append(t.cost)
|
|
158
|
+
elif t.context >= 500_000:
|
|
159
|
+
big_turns.append(t.cost)
|
|
160
|
+
# Most of the context written to cache at once = the cache was rebuilt.
|
|
161
|
+
written = t.parts["cache writes"]
|
|
162
|
+
if prev and prev.t and t.t and written > 0 and t.context > 20_000:
|
|
163
|
+
life = 3600 if t.write_1h else 300
|
|
164
|
+
if t.t - prev.t > life and written > 0.5 * t.cost:
|
|
165
|
+
idle_rebuilds += written
|
|
166
|
+
prev = t
|
|
167
|
+
session_costs.append(s_cost)
|
|
168
|
+
|
|
169
|
+
# Re-reads of the same file and range; runs of three identical calls.
|
|
170
|
+
seen_reads = Counter()
|
|
171
|
+
sequence = []
|
|
172
|
+
for tid, (name, inp, side) in uses.items():
|
|
173
|
+
if name == "Read":
|
|
174
|
+
reads += 1
|
|
175
|
+
key = (inp.get("file_path"), inp.get("offset"), inp.get("limit"))
|
|
176
|
+
seen_reads[key] += 1
|
|
177
|
+
if seen_reads[key] > 1:
|
|
178
|
+
rereads += 1
|
|
179
|
+
if not side:
|
|
180
|
+
sequence.append(name + json.dumps({k: v for k, v in inp.items() if k != "description"}, sort_keys=True)[:400])
|
|
181
|
+
run = 1
|
|
182
|
+
for a, b in zip(sequence, sequence[1:]):
|
|
183
|
+
run = run + 1 if a == b else 1
|
|
184
|
+
if run == 3:
|
|
185
|
+
loops += 1
|
|
186
|
+
# Big tool results, re-sent from cache on every later turn.
|
|
187
|
+
main_times = [(t.t, model_weight(t.model)) for t in turns if not t.side and t.t]
|
|
188
|
+
for tid, (when, chars) in results.items():
|
|
189
|
+
if chars > BIG_RESULT_CHARS and when:
|
|
190
|
+
later = sum(w for (tt, w) in main_times if tt > when)
|
|
191
|
+
big_result_carry += chars / CHARS_PER_TOKEN * 0.1 * later
|
|
192
|
+
|
|
193
|
+
if not total:
|
|
194
|
+
return None
|
|
195
|
+
session_costs.sort(reverse=True)
|
|
196
|
+
top = session_costs[: max(1, len(session_costs) // 10)]
|
|
197
|
+
contexts.sort()
|
|
198
|
+
return {
|
|
199
|
+
"days": min(days or 10**6, max(1, round(((last or 0) - (first or 0)) / 86400))) if first else 0,
|
|
200
|
+
"sessions": len(session_costs),
|
|
201
|
+
"turns": turn_count,
|
|
202
|
+
"parts": {k: v / total for k, v in parts.items()},
|
|
203
|
+
"models": {k: v / total for k, v in by_model.most_common(4)},
|
|
204
|
+
"top_tenth_share": sum(top) / total,
|
|
205
|
+
"biggest_session_share": session_costs[0] / total,
|
|
206
|
+
"big_context_share": big_context / total,
|
|
207
|
+
"median_context": contexts[len(contexts) // 2] if contexts else 0,
|
|
208
|
+
"p90_context": contexts[int(0.9 * len(contexts))] if contexts else 0,
|
|
209
|
+
"big_vs_small": (sum(big_turns) / len(big_turns)) / (sum(small_turns) / len(small_turns)) if big_turns and small_turns else None,
|
|
210
|
+
"fresh_start_saving": min(fresh_start_saving / total, 0.95),
|
|
211
|
+
"blamed": {
|
|
212
|
+
"subagents": subagents / total,
|
|
213
|
+
"cache rebuilt after a break": idle_rebuilds / total,
|
|
214
|
+
"huge tool outputs": min(big_result_carry / total, 1.0),
|
|
215
|
+
},
|
|
216
|
+
"rereads": (rereads, reads),
|
|
217
|
+
"loops": loops,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def pct(x: float) -> str:
|
|
222
|
+
return f"{100 * x:.0f}%" if x >= 0.01 or x == 0 else "<1%"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def report(r: dict) -> str:
|
|
226
|
+
k = lambda n: f"{n / 1000:.0f}k"
|
|
227
|
+
lines = [
|
|
228
|
+
f"contexttax · Claude Code · last {r['days']} days · {r['sessions']} sessions · {r['turns']:,} model calls",
|
|
229
|
+
"",
|
|
230
|
+
f" {pct(r['big_context_share'])} of your usage went to turns where the conversation was already over {k(BIG_CONTEXT)} tokens.",
|
|
231
|
+
f" Your biggest 10% of sessions used {pct(r['top_tenth_share'])}. One session alone used {pct(r['biggest_session_share'])}.",
|
|
232
|
+
]
|
|
233
|
+
if r["big_vs_small"]:
|
|
234
|
+
lines.append(f" A turn at 500k+ context costs {r['big_vs_small']:.1f}x a turn at 50-100k.")
|
|
235
|
+
lines += [
|
|
236
|
+
f" Median turn: {k(r['median_context'])} of context. One in ten: over {k(r['p90_context'])}.",
|
|
237
|
+
"",
|
|
238
|
+
f" Starting fresh with a short summary at {k(BIG_CONTEXT)} would have saved about {pct(r['fresh_start_saving'])}.",
|
|
239
|
+
"",
|
|
240
|
+
" What people usually blame:",
|
|
241
|
+
f" re-reading the same file {r['rereads'][0]} of {r['rereads'][1]} reads",
|
|
242
|
+
f" loops (same call 3 times) {r['loops']}",
|
|
243
|
+
f" huge tool outputs {pct(r['blamed']['huge tool outputs'])}",
|
|
244
|
+
f" subagents {pct(r['blamed']['subagents'])}",
|
|
245
|
+
f" cache rebuilt after a break {pct(r['blamed']['cache rebuilt after a break'])}",
|
|
246
|
+
"",
|
|
247
|
+
" Where it goes: " + ", ".join(f"{name} {pct(v)}" for name, v in sorted(r["parts"].items(), key=lambda x: -x[1]) if v >= 0.005),
|
|
248
|
+
" Models: " + ", ".join(f"{m} {pct(v)}" for m, v in r["models"].items() if v >= 0.005),
|
|
249
|
+
"",
|
|
250
|
+
" Weighted by API prices. Read on this computer; nothing was sent anywhere.",
|
|
251
|
+
" github.com/theadamdanielsson/contexttax",
|
|
252
|
+
]
|
|
253
|
+
return "\n".join(lines)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main(argv=None) -> int:
|
|
257
|
+
p = argparse.ArgumentParser(prog="contexttax", description="Where your Claude Code usage actually goes. Reads local logs only.")
|
|
258
|
+
p.add_argument("--days", type=int, default=30, help="look back this many days (default 30; 0 = all)")
|
|
259
|
+
p.add_argument("--json", action="store_true", help="print the numbers as JSON")
|
|
260
|
+
p.add_argument("--path", default=os.path.expanduser("~/.claude/projects"), help="where Claude Code keeps its logs")
|
|
261
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
262
|
+
a = p.parse_args(argv)
|
|
263
|
+
if not os.path.isdir(a.path):
|
|
264
|
+
print(f"No Claude Code logs found at {a.path}.", file=sys.stderr)
|
|
265
|
+
return 1
|
|
266
|
+
r = analyse(a.path, a.days or None)
|
|
267
|
+
if not r:
|
|
268
|
+
print("No model calls found in those logs.", file=sys.stderr)
|
|
269
|
+
return 1
|
|
270
|
+
print(json.dumps(r, indent=2) if a.json else report(r))
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
sys.exit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contexttax"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Where your Claude Code usage actually goes. Reads local logs only."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Adam Danielsson" }]
|
|
13
|
+
keywords = ["claude-code", "usage", "limits", "tokens", "context"]
|
|
14
|
+
classifiers = ["Environment :: Console", "Programming Language :: Python :: 3"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/theadamdanielsson/contexttax"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
contexttax = "contexttax:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
py-modules = ["contexttax"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""contexttax on a small made-up log: long-context turns, a sub-agent, a re-read and a loop."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
10
|
+
import contexttax # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def call(i, read, written=0, out=100, side=False, model="claude-opus-5-5", t="2026-10-20T10:00:00Z", tools=()):
|
|
14
|
+
content = [{"type": "tool_use", "id": f"tu{i}-{n}", "name": name, "input": inp} for n, (name, inp) in enumerate(tools)]
|
|
15
|
+
return {
|
|
16
|
+
"type": "assistant", "isSidechain": side, "timestamp": t,
|
|
17
|
+
"message": {"id": f"m{i}", "model": model, "content": content,
|
|
18
|
+
"usage": {"input_tokens": 0, "cache_read_input_tokens": read, "cache_creation_input_tokens": written,
|
|
19
|
+
"cache_creation": {"ephemeral_1h_input_tokens": written, "ephemeral_5m_input_tokens": 0}, "output_tokens": out}},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContextTax(unittest.TestCase):
|
|
24
|
+
def setUp(self):
|
|
25
|
+
self.dir = tempfile.mkdtemp()
|
|
26
|
+
read = {"file_path": "/a.py"}
|
|
27
|
+
lines = [
|
|
28
|
+
call(1, 50_000, 10_000, tools=[("Read", read)]),
|
|
29
|
+
call(2, 60_000, tools=[("Read", read)]),
|
|
30
|
+
call(3, 300_000, tools=[("Bash", {"command": "zzsecretzz", "description": "a"})]),
|
|
31
|
+
call(4, 300_000, tools=[("Bash", {"command": "zzsecretzz", "description": "b"})]),
|
|
32
|
+
call(5, 600_000, tools=[("Bash", {"command": "zzsecretzz", "description": "c"})]),
|
|
33
|
+
call(6, 20_000, side=True),
|
|
34
|
+
# The same message logged twice (one line per content block) counts once.
|
|
35
|
+
call(6, 20_000, side=True),
|
|
36
|
+
]
|
|
37
|
+
os.makedirs(os.path.join(self.dir, "proj"))
|
|
38
|
+
with open(os.path.join(self.dir, "proj", "s.jsonl"), "w") as f:
|
|
39
|
+
for line in lines:
|
|
40
|
+
f.write(json.dumps(line) + "\n")
|
|
41
|
+
f.write("not json\n")
|
|
42
|
+
|
|
43
|
+
def test_numbers(self):
|
|
44
|
+
r = contexttax.analyse(self.dir, None)
|
|
45
|
+
self.assertEqual(r["sessions"], 1)
|
|
46
|
+
self.assertEqual(r["turns"], 6)
|
|
47
|
+
self.assertEqual(r["rereads"], (1, 2))
|
|
48
|
+
self.assertEqual(r["loops"], 1) # three identical shell calls; the description is ignored
|
|
49
|
+
self.assertGreater(r["big_context_share"], 0.6)
|
|
50
|
+
self.assertGreater(r["blamed"]["subagents"], 0)
|
|
51
|
+
self.assertAlmostEqual(sum(r["parts"].values()), 1.0, places=6)
|
|
52
|
+
|
|
53
|
+
def test_report_has_no_log_text(self):
|
|
54
|
+
out = contexttax.report(contexttax.analyse(self.dir, None))
|
|
55
|
+
self.assertNotIn("/a.py", out)
|
|
56
|
+
self.assertNotIn("zzsecretzz", out)
|
|
57
|
+
|
|
58
|
+
def test_missing_logs(self):
|
|
59
|
+
self.assertEqual(contexttax.main(["--path", os.path.join(self.dir, "nope")]), 1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
unittest.main()
|