claude-context-cost 1.3.1__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.
- claude_context_cost-1.3.1/LICENSE +21 -0
- claude_context_cost-1.3.1/PKG-INFO +200 -0
- claude_context_cost-1.3.1/README.md +176 -0
- claude_context_cost-1.3.1/claude_context_cost/__init__.py +15 -0
- claude_context_cost-1.3.1/claude_context_cost/__main__.py +5 -0
- claude_context_cost-1.3.1/claude_context_cost/attribute.py +175 -0
- claude_context_cost-1.3.1/claude_context_cost/cli.py +191 -0
- claude_context_cost-1.3.1/claude_context_cost/example.jsonl +33 -0
- claude_context_cost-1.3.1/claude_context_cost/summary.py +108 -0
- claude_context_cost-1.3.1/claude_context_cost/transcript.py +262 -0
- claude_context_cost-1.3.1/claude_context_cost.egg-info/PKG-INFO +200 -0
- claude_context_cost-1.3.1/claude_context_cost.egg-info/SOURCES.txt +22 -0
- claude_context_cost-1.3.1/claude_context_cost.egg-info/dependency_links.txt +1 -0
- claude_context_cost-1.3.1/claude_context_cost.egg-info/entry_points.txt +2 -0
- claude_context_cost-1.3.1/claude_context_cost.egg-info/top_level.txt +1 -0
- claude_context_cost-1.3.1/pyproject.toml +45 -0
- claude_context_cost-1.3.1/setup.cfg +4 -0
- claude_context_cost-1.3.1/tests/test_acceptance.py +252 -0
- claude_context_cost-1.3.1/tests/test_cli.py +123 -0
- claude_context_cost-1.3.1/tests/test_corpus.py +82 -0
- claude_context_cost-1.3.1/tests/test_package.py +44 -0
- claude_context_cost-1.3.1/tests/test_readme.py +32 -0
- claude_context_cost-1.3.1/tests/test_showcase.py +43 -0
- claude_context_cost-1.3.1/tests/test_vitrine.py +245 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter
|
|
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,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-context-cost
|
|
3
|
+
Version: 1.3.1
|
|
4
|
+
Summary: How much each source (CLAUDE.md, rules, skills, memory, tool results) costs in the context of a Claude Code session, counted from the API usage in its transcript.
|
|
5
|
+
Author: Peter
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/peterwkdev-creator/claude-context-cost
|
|
8
|
+
Project-URL: Issues, https://github.com/peterwkdev-creator/claude-context-cost/issues
|
|
9
|
+
Keywords: claude-code,context-window,tokens,transcript,cli
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# claude-context-cost
|
|
26
|
+
|
|
27
|
+
How much each source (your `CLAUDE.md`, rules, skills, memory, tool results)
|
|
28
|
+
costs in the context of a Claude Code session, counted from the API `usage`
|
|
29
|
+
recorded in its transcript, not estimated from characters.
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
**[Try it in the browser](https://peterwkdev-creator.github.io/claude-context-cost/)**:
|
|
34
|
+
choose one of your transcripts, or run the synthetic example. The library runs
|
|
35
|
+
in the page (Pyodide); your file is never uploaded.
|
|
36
|
+
|
|
37
|
+
The usual estimate, characters / 4, **undercounts by 40 to 45%**: −45% over
|
|
38
|
+
5,365 steps of the author's sessions, mostly in Portuguese, and −40% in an
|
|
39
|
+
English-language session (2026-10-10). Here every response adds up to its
|
|
40
|
+
`usage` exactly, in all 457 transcripts tested.
|
|
41
|
+
|
|
42
|
+
> Reads only the transcripts under `~/.claude/projects` on your machine.
|
|
43
|
+
> Nothing is sent anywhere. The numbers are tokens, not dollars.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install claude-context-cost
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.11 or newer, no dependencies. To install a tagged version straight
|
|
52
|
+
from GitHub instead:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install "git+https://github.com/peterwkdev-creator/claude-context-cost@v1.3.1"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
claude-context-cost --example
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude-context-cost ~/.claude/projects/<project-folder>/<session-id>.jsonl
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The newest `.jsonl` in a project's folder is its latest session (on Windows,
|
|
69
|
+
`~` is `%USERPROFILE%`). A subagent has its own transcript,
|
|
70
|
+
`<session-id>/subagents/agent-<id>.jsonl`.
|
|
71
|
+
|
|
72
|
+
Pass a project's folder instead of a file to sum its sessions and their
|
|
73
|
+
subagents: the costliest sources first, then one line per session. Pass
|
|
74
|
+
`~/.claude/projects` itself to sum every project. Tools and your own
|
|
75
|
+
`~/.claude/...` files add up across projects; a project's `CLAUDE.md` and
|
|
76
|
+
rules stay apart, marked with the project.
|
|
77
|
+
|
|
78
|
+
`--steps` shows what entered before each response, `--all` lists every
|
|
79
|
+
source, `--json` is for scripts (for a folder too). On the synthetic example
|
|
80
|
+
shipped with the package:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
cost share entered in chars/4 error how source
|
|
84
|
+
128,240 80.6% 36,640 7 - - - unattributed: system prompt, tools, overhead
|
|
85
|
+
10,400 6.5% 3,200 4 2,000 -38% measured, split tool: Read
|
|
86
|
+
5,600 3.5% 1,600 7 1,000 -38% estimated claude.md: CLAUDE.md
|
|
87
|
+
1,600 1.0% 800 4 500 -38% split, estimated path rule: .claude/rules/python.md
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **cost**: tokens × the responses the source stayed in context, until a
|
|
91
|
+
compaction removed it. This is what you pay for over the session.
|
|
92
|
+
- **entered**: the tokens it brought in; **in**: the responses it stayed for.
|
|
93
|
+
- **chars/4** and **error**: what the usual estimate would say, beside it.
|
|
94
|
+
|
|
95
|
+
From Python:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from claude_context_cost import analyze, example_path
|
|
99
|
+
|
|
100
|
+
report = analyze(example_path()) # or the path of any .jsonl transcript
|
|
101
|
+
print(report.total) # 159160: input tokens summed over the 7 responses
|
|
102
|
+
read = report.sources[("tool", "Read")]
|
|
103
|
+
print(read.entered, read.responses, read.cost) # 3200 4 10400
|
|
104
|
+
print(read.estimate_div4) # 2000.0: characters / 4 for the same text
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`summarize(folder)` returns the same sum as a folder on the command line:
|
|
108
|
+
`.sources` maps `(kind, name, project)` to the totals, with `project` set only
|
|
109
|
+
for a project's own files.
|
|
110
|
+
|
|
111
|
+
## Why
|
|
112
|
+
|
|
113
|
+
[claude-devtools](https://github.com/matt1398/claude-devtools) already shows
|
|
114
|
+
the context by source. Read in its code at commit `1486f20` (the latest on
|
|
115
|
+
2026-10-10):
|
|
116
|
+
|
|
117
|
+
- Each source's tokens are characters / 4 (`src/main/utils/tokenizer.ts`);
|
|
118
|
+
only the turn total comes from the `usage`.
|
|
119
|
+
- Rules in `.claude/rules/` are listed as global sources without reading
|
|
120
|
+
their `paths:`, so a rule that never loaded still counts. Issue
|
|
121
|
+
[#224](https://github.com/matt1398/claude-devtools/issues/224), open since
|
|
122
|
+
2026-07-17.
|
|
123
|
+
- `CLAUDE.md` and rules are read from disk today, not as they were during the
|
|
124
|
+
session.
|
|
125
|
+
|
|
126
|
+
This tool fixes the first two from the transcript alone.
|
|
127
|
+
|
|
128
|
+
## What it counts
|
|
129
|
+
|
|
130
|
+
- **One response per `message.id`.** A response is written over several
|
|
131
|
+
lines with the same `usage`; it counts once.
|
|
132
|
+
- **Context = `input_tokens` + `cache_read_input_tokens` +
|
|
133
|
+
`cache_creation_input_tokens`.**
|
|
134
|
+
- **What entered** between two responses is the context growth minus the
|
|
135
|
+
previous response's output. When a single source entered, its tokens are
|
|
136
|
+
**measured**. When several did, the growth is **split** by their size.
|
|
137
|
+
- **At the start of the session and after each compaction** the context
|
|
138
|
+
jumps at once: each source is **estimated** with the bytes-per-token ratio
|
|
139
|
+
measured in the same transcript (median of its text-only steps; 2.25 when
|
|
140
|
+
it has fewer than 3), and the rest is **unattributed** (system prompt,
|
|
141
|
+
tool definitions). So every response adds up to its `usage`.
|
|
142
|
+
- **Rules with `paths:`** count only from the response where Claude Code
|
|
143
|
+
loaded them (`nested_memory`), never before.
|
|
144
|
+
- **Only what Claude Code rendered** into the context counts; bookkeeping
|
|
145
|
+
lines in the transcript do not.
|
|
146
|
+
- **A compaction** removes everything; messages it keeps count as "kept by
|
|
147
|
+
compaction", not as new responses.
|
|
148
|
+
|
|
149
|
+
## How it is tested
|
|
150
|
+
|
|
151
|
+
- A synthetic transcript with known values (`tests/fixtures/build_example.py`)
|
|
152
|
+
where every source's tokens are worked out by hand: each one comes out
|
|
153
|
+
exact, every response adds up to its `usage`, a `paths:` rule that never
|
|
154
|
+
loaded counts 0, a response written over several lines counts once.
|
|
155
|
+
- `python tests/mutate.py` breaks each of those rules in a copy of the code
|
|
156
|
+
(17 mutants); the tests catch all 17.
|
|
157
|
+
- On the author's machine (2026-10-10): 75 session transcripts and 382
|
|
158
|
+
subagent transcripts, 91,347 responses, 0 where the sources differ from the
|
|
159
|
+
`usage`, 0 where the response count differs from an independent count, and
|
|
160
|
+
the sum over every project takes every transcript. The
|
|
161
|
+
ratio was measured in 50 of the 73 sessions with responses, median 2.22
|
|
162
|
+
bytes per token. Run it on yours with
|
|
163
|
+
`CLAUDE_CONTEXT_COST_CORPUS=1 python -m unittest tests.test_corpus -v`
|
|
164
|
+
(it prints only counts).
|
|
165
|
+
|
|
166
|
+
## Limits
|
|
167
|
+
|
|
168
|
+
- **Split and estimated tokens are not measured.** The `how` column says
|
|
169
|
+
which is which. Many steps bring a tool result together with a small
|
|
170
|
+
reminder, so splitting is common.
|
|
171
|
+
- **Output includes thinking.** Thinking may leave the context later, so
|
|
172
|
+
the previous output can be overcounted; the remainder then shows as
|
|
173
|
+
negative unattributed tokens (191 of 91,347 responses).
|
|
174
|
+
- **A session's total does not include its subagents**; the sum of its
|
|
175
|
+
project's folder does.
|
|
176
|
+
- **English was measured in one session** (2026-10-10): 8 file reads, a
|
|
177
|
+
license, release notes, two READMEs, three Python modules and a git log;
|
|
178
|
+
median −40%, from −29% to −47%. The other numbers come from one person's
|
|
179
|
+
sessions, mostly in Portuguese.
|
|
180
|
+
- The transcript format is not documented and changes between versions.
|
|
181
|
+
|
|
182
|
+
## Compatibility
|
|
183
|
+
|
|
184
|
+
Transcripts of Claude Code 2.1 (the synthetic fixture follows 2.1.293).
|
|
185
|
+
Python 3.11 to 3.14, tested on Linux and Windows.
|
|
186
|
+
|
|
187
|
+
Versions follow [Semantic Versioning](https://semver.org/): the names in
|
|
188
|
+
`claude_context_cost.__all__` and the keys of `--json` change incompatibly only
|
|
189
|
+
in a new major version. The transcript format is Claude Code's and changes
|
|
190
|
+
without notice; when it does, a fix keeps the API and ships as a minor or
|
|
191
|
+
patch version.
|
|
192
|
+
|
|
193
|
+
## Contributing
|
|
194
|
+
|
|
195
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Never paste a real transcript in an
|
|
196
|
+
issue or pull request: it holds paths, file contents and sometimes secrets.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# claude-context-cost
|
|
2
|
+
|
|
3
|
+
How much each source (your `CLAUDE.md`, rules, skills, memory, tool results)
|
|
4
|
+
costs in the context of a Claude Code session, counted from the API `usage`
|
|
5
|
+
recorded in its transcript, not estimated from characters.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
**[Try it in the browser](https://peterwkdev-creator.github.io/claude-context-cost/)**:
|
|
10
|
+
choose one of your transcripts, or run the synthetic example. The library runs
|
|
11
|
+
in the page (Pyodide); your file is never uploaded.
|
|
12
|
+
|
|
13
|
+
The usual estimate, characters / 4, **undercounts by 40 to 45%**: −45% over
|
|
14
|
+
5,365 steps of the author's sessions, mostly in Portuguese, and −40% in an
|
|
15
|
+
English-language session (2026-10-10). Here every response adds up to its
|
|
16
|
+
`usage` exactly, in all 457 transcripts tested.
|
|
17
|
+
|
|
18
|
+
> Reads only the transcripts under `~/.claude/projects` on your machine.
|
|
19
|
+
> Nothing is sent anywhere. The numbers are tokens, not dollars.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install claude-context-cost
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Python 3.11 or newer, no dependencies. To install a tagged version straight
|
|
28
|
+
from GitHub instead:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install "git+https://github.com/peterwkdev-creator/claude-context-cost@v1.3.1"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude-context-cost --example
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude-context-cost ~/.claude/projects/<project-folder>/<session-id>.jsonl
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The newest `.jsonl` in a project's folder is its latest session (on Windows,
|
|
45
|
+
`~` is `%USERPROFILE%`). A subagent has its own transcript,
|
|
46
|
+
`<session-id>/subagents/agent-<id>.jsonl`.
|
|
47
|
+
|
|
48
|
+
Pass a project's folder instead of a file to sum its sessions and their
|
|
49
|
+
subagents: the costliest sources first, then one line per session. Pass
|
|
50
|
+
`~/.claude/projects` itself to sum every project. Tools and your own
|
|
51
|
+
`~/.claude/...` files add up across projects; a project's `CLAUDE.md` and
|
|
52
|
+
rules stay apart, marked with the project.
|
|
53
|
+
|
|
54
|
+
`--steps` shows what entered before each response, `--all` lists every
|
|
55
|
+
source, `--json` is for scripts (for a folder too). On the synthetic example
|
|
56
|
+
shipped with the package:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
cost share entered in chars/4 error how source
|
|
60
|
+
128,240 80.6% 36,640 7 - - - unattributed: system prompt, tools, overhead
|
|
61
|
+
10,400 6.5% 3,200 4 2,000 -38% measured, split tool: Read
|
|
62
|
+
5,600 3.5% 1,600 7 1,000 -38% estimated claude.md: CLAUDE.md
|
|
63
|
+
1,600 1.0% 800 4 500 -38% split, estimated path rule: .claude/rules/python.md
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **cost**: tokens × the responses the source stayed in context, until a
|
|
67
|
+
compaction removed it. This is what you pay for over the session.
|
|
68
|
+
- **entered**: the tokens it brought in; **in**: the responses it stayed for.
|
|
69
|
+
- **chars/4** and **error**: what the usual estimate would say, beside it.
|
|
70
|
+
|
|
71
|
+
From Python:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from claude_context_cost import analyze, example_path
|
|
75
|
+
|
|
76
|
+
report = analyze(example_path()) # or the path of any .jsonl transcript
|
|
77
|
+
print(report.total) # 159160: input tokens summed over the 7 responses
|
|
78
|
+
read = report.sources[("tool", "Read")]
|
|
79
|
+
print(read.entered, read.responses, read.cost) # 3200 4 10400
|
|
80
|
+
print(read.estimate_div4) # 2000.0: characters / 4 for the same text
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`summarize(folder)` returns the same sum as a folder on the command line:
|
|
84
|
+
`.sources` maps `(kind, name, project)` to the totals, with `project` set only
|
|
85
|
+
for a project's own files.
|
|
86
|
+
|
|
87
|
+
## Why
|
|
88
|
+
|
|
89
|
+
[claude-devtools](https://github.com/matt1398/claude-devtools) already shows
|
|
90
|
+
the context by source. Read in its code at commit `1486f20` (the latest on
|
|
91
|
+
2026-10-10):
|
|
92
|
+
|
|
93
|
+
- Each source's tokens are characters / 4 (`src/main/utils/tokenizer.ts`);
|
|
94
|
+
only the turn total comes from the `usage`.
|
|
95
|
+
- Rules in `.claude/rules/` are listed as global sources without reading
|
|
96
|
+
their `paths:`, so a rule that never loaded still counts. Issue
|
|
97
|
+
[#224](https://github.com/matt1398/claude-devtools/issues/224), open since
|
|
98
|
+
2026-07-17.
|
|
99
|
+
- `CLAUDE.md` and rules are read from disk today, not as they were during the
|
|
100
|
+
session.
|
|
101
|
+
|
|
102
|
+
This tool fixes the first two from the transcript alone.
|
|
103
|
+
|
|
104
|
+
## What it counts
|
|
105
|
+
|
|
106
|
+
- **One response per `message.id`.** A response is written over several
|
|
107
|
+
lines with the same `usage`; it counts once.
|
|
108
|
+
- **Context = `input_tokens` + `cache_read_input_tokens` +
|
|
109
|
+
`cache_creation_input_tokens`.**
|
|
110
|
+
- **What entered** between two responses is the context growth minus the
|
|
111
|
+
previous response's output. When a single source entered, its tokens are
|
|
112
|
+
**measured**. When several did, the growth is **split** by their size.
|
|
113
|
+
- **At the start of the session and after each compaction** the context
|
|
114
|
+
jumps at once: each source is **estimated** with the bytes-per-token ratio
|
|
115
|
+
measured in the same transcript (median of its text-only steps; 2.25 when
|
|
116
|
+
it has fewer than 3), and the rest is **unattributed** (system prompt,
|
|
117
|
+
tool definitions). So every response adds up to its `usage`.
|
|
118
|
+
- **Rules with `paths:`** count only from the response where Claude Code
|
|
119
|
+
loaded them (`nested_memory`), never before.
|
|
120
|
+
- **Only what Claude Code rendered** into the context counts; bookkeeping
|
|
121
|
+
lines in the transcript do not.
|
|
122
|
+
- **A compaction** removes everything; messages it keeps count as "kept by
|
|
123
|
+
compaction", not as new responses.
|
|
124
|
+
|
|
125
|
+
## How it is tested
|
|
126
|
+
|
|
127
|
+
- A synthetic transcript with known values (`tests/fixtures/build_example.py`)
|
|
128
|
+
where every source's tokens are worked out by hand: each one comes out
|
|
129
|
+
exact, every response adds up to its `usage`, a `paths:` rule that never
|
|
130
|
+
loaded counts 0, a response written over several lines counts once.
|
|
131
|
+
- `python tests/mutate.py` breaks each of those rules in a copy of the code
|
|
132
|
+
(17 mutants); the tests catch all 17.
|
|
133
|
+
- On the author's machine (2026-10-10): 75 session transcripts and 382
|
|
134
|
+
subagent transcripts, 91,347 responses, 0 where the sources differ from the
|
|
135
|
+
`usage`, 0 where the response count differs from an independent count, and
|
|
136
|
+
the sum over every project takes every transcript. The
|
|
137
|
+
ratio was measured in 50 of the 73 sessions with responses, median 2.22
|
|
138
|
+
bytes per token. Run it on yours with
|
|
139
|
+
`CLAUDE_CONTEXT_COST_CORPUS=1 python -m unittest tests.test_corpus -v`
|
|
140
|
+
(it prints only counts).
|
|
141
|
+
|
|
142
|
+
## Limits
|
|
143
|
+
|
|
144
|
+
- **Split and estimated tokens are not measured.** The `how` column says
|
|
145
|
+
which is which. Many steps bring a tool result together with a small
|
|
146
|
+
reminder, so splitting is common.
|
|
147
|
+
- **Output includes thinking.** Thinking may leave the context later, so
|
|
148
|
+
the previous output can be overcounted; the remainder then shows as
|
|
149
|
+
negative unattributed tokens (191 of 91,347 responses).
|
|
150
|
+
- **A session's total does not include its subagents**; the sum of its
|
|
151
|
+
project's folder does.
|
|
152
|
+
- **English was measured in one session** (2026-10-10): 8 file reads, a
|
|
153
|
+
license, release notes, two READMEs, three Python modules and a git log;
|
|
154
|
+
median −40%, from −29% to −47%. The other numbers come from one person's
|
|
155
|
+
sessions, mostly in Portuguese.
|
|
156
|
+
- The transcript format is not documented and changes between versions.
|
|
157
|
+
|
|
158
|
+
## Compatibility
|
|
159
|
+
|
|
160
|
+
Transcripts of Claude Code 2.1 (the synthetic fixture follows 2.1.293).
|
|
161
|
+
Python 3.11 to 3.14, tested on Linux and Windows.
|
|
162
|
+
|
|
163
|
+
Versions follow [Semantic Versioning](https://semver.org/): the names in
|
|
164
|
+
`claude_context_cost.__all__` and the keys of `--json` change incompatibly only
|
|
165
|
+
in a new major version. The transcript format is Claude Code's and changes
|
|
166
|
+
without notice; when it does, a fix keeps the API and ships as a minor or
|
|
167
|
+
patch version.
|
|
168
|
+
|
|
169
|
+
## Contributing
|
|
170
|
+
|
|
171
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Never paste a real transcript in an
|
|
172
|
+
issue or pull request: it holds paths, file contents and sometimes secrets.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""How much each source costs in the context of a Claude Code session, counted from the usage."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .attribute import Report, Source, analyze, attribute
|
|
5
|
+
from .summary import Summary, Total, summarize
|
|
6
|
+
from .transcript import read
|
|
7
|
+
|
|
8
|
+
__version__ = "1.3.1"
|
|
9
|
+
__all__ = ["Report", "Source", "analyze", "attribute", "example_path", "read", "Summary", "summarize",
|
|
10
|
+
"Total", "__version__"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def example_path():
|
|
14
|
+
"""A synthetic transcript shipped with the package, for trying the tool without a real one."""
|
|
15
|
+
return Path(__file__).resolve().parent / "example.jsonl"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Gives every token of every response's input context to a source.
|
|
2
|
+
|
|
3
|
+
Between two responses of the same segment, the tokens that entered are known
|
|
4
|
+
exactly from the usage:
|
|
5
|
+
|
|
6
|
+
entered(n) = context(n) - context(n-1) - output(n-1)
|
|
7
|
+
|
|
8
|
+
One item in between: it gets all of them ("measured"). Several: they share
|
|
9
|
+
them in proportion to their bytes ("split"). At the first response of a
|
|
10
|
+
segment (session start or right after a compaction) the context also holds
|
|
11
|
+
the system prompt and the tools, which are not in the transcript: each item
|
|
12
|
+
gets its bytes divided by the transcript's own bytes-per-token ratio
|
|
13
|
+
("estimated") and the remainder is unattributed.
|
|
14
|
+
|
|
15
|
+
So in every response the sources plus the unattributed add up to the usage,
|
|
16
|
+
and the cost of a source is its tokens summed over every response it stayed
|
|
17
|
+
in, until a compaction drops it.
|
|
18
|
+
"""
|
|
19
|
+
import statistics
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from .transcript import read
|
|
23
|
+
|
|
24
|
+
# Measured in October 2026 on 1,831 pairs from 12 projects (mostly Portuguese):
|
|
25
|
+
# 2.25 bytes per token. Used only when a transcript has too few pairs of its own.
|
|
26
|
+
DEFAULT_BYTES_PER_TOKEN = 2.25
|
|
27
|
+
MIN_PAIR_BYTES = 2000
|
|
28
|
+
MAX_PAIR_OUTPUT = 400 # the previous output includes thinking that may not come back
|
|
29
|
+
MIN_PAIRS = 3
|
|
30
|
+
|
|
31
|
+
OUTPUT = ("output", "responses")
|
|
32
|
+
FLOOR = ("unattributed", "system prompt, tools, overhead")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Source:
|
|
37
|
+
kind: str
|
|
38
|
+
name: str
|
|
39
|
+
entered: int = 0 # tokens that entered the context, summed over every time it entered
|
|
40
|
+
bytes: int = 0
|
|
41
|
+
chars: int = 0
|
|
42
|
+
opaque: int = 0 # items whose size is not in the text (images, tool references)
|
|
43
|
+
cost: int = 0 # tokens x responses it stayed in
|
|
44
|
+
responses: int = 0
|
|
45
|
+
how: dict = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def estimate_div4(self):
|
|
49
|
+
"""What a characters / 4 estimate would say it entered, or None if it has no text."""
|
|
50
|
+
return self.chars / 4 if self.chars and not self.opaque else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Report:
|
|
55
|
+
path: str
|
|
56
|
+
responses: list
|
|
57
|
+
compactions: int
|
|
58
|
+
bad_lines: int
|
|
59
|
+
bytes_per_token: float
|
|
60
|
+
ratio_pairs: int
|
|
61
|
+
ratio_is_default: bool
|
|
62
|
+
sources: dict
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def total(self):
|
|
66
|
+
return sum(r.context for r in self.responses)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def allocate(total, weights):
|
|
70
|
+
"""Integers proportional to the weights that add up to total exactly (largest remainder)."""
|
|
71
|
+
s = sum(weights)
|
|
72
|
+
if not weights or s <= 0:
|
|
73
|
+
return [0] * len(weights)
|
|
74
|
+
raw = [total * w / s for w in weights]
|
|
75
|
+
out = [int(x // 1) for x in raw]
|
|
76
|
+
order = sorted(range(len(raw)), key=lambda i: raw[i] - out[i], reverse=True)
|
|
77
|
+
for i in order[:total - sum(out)]:
|
|
78
|
+
out[i] += 1
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def calibrate(responses):
|
|
83
|
+
"""Bytes per token from the steps where only text entered: the median, or the default."""
|
|
84
|
+
ratios = []
|
|
85
|
+
for prev, r in zip(responses, responses[1:]):
|
|
86
|
+
if r.segment_start or not r.entered or any(i.opaque for i in r.entered):
|
|
87
|
+
continue
|
|
88
|
+
delta = r.context - prev.context - prev.output
|
|
89
|
+
size = sum(i.bytes for i in r.entered)
|
|
90
|
+
if size >= MIN_PAIR_BYTES and prev.output < MAX_PAIR_OUTPUT and delta > 0:
|
|
91
|
+
ratios.append(size / delta)
|
|
92
|
+
if len(ratios) >= MIN_PAIRS:
|
|
93
|
+
return statistics.median(ratios), len(ratios), False
|
|
94
|
+
return DEFAULT_BYTES_PER_TOKEN, len(ratios), True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _estimated(items, budget, ratio):
|
|
98
|
+
"""Segment start: bytes / ratio for each item, never more than the budget; returns the rest."""
|
|
99
|
+
known = [i for i in items if not i.opaque]
|
|
100
|
+
est = round(sum(i.bytes for i in known) / ratio)
|
|
101
|
+
share = allocate(min(est, max(budget, 0)), [i.bytes for i in known])
|
|
102
|
+
for i, t in zip(known, share):
|
|
103
|
+
i.tokens, i.how = t, "estimated"
|
|
104
|
+
for i in items:
|
|
105
|
+
if i.opaque:
|
|
106
|
+
i.tokens, i.how = 0, "unknown"
|
|
107
|
+
return budget - sum(share)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _split(items, delta, ratio):
|
|
111
|
+
"""Between two responses: all of delta goes to the items; returns what is left unattributed."""
|
|
112
|
+
if delta <= 0:
|
|
113
|
+
for i in items:
|
|
114
|
+
i.tokens, i.how = 0, "shrank"
|
|
115
|
+
return delta
|
|
116
|
+
if len(items) == 1:
|
|
117
|
+
items[0].tokens, items[0].how = delta, "measured"
|
|
118
|
+
return 0
|
|
119
|
+
known = [i for i in items if not i.opaque]
|
|
120
|
+
opaque = [i for i in items if i.opaque]
|
|
121
|
+
if opaque:
|
|
122
|
+
text = min(round(sum(i.bytes for i in known) / ratio), delta)
|
|
123
|
+
shares = allocate(text, [i.bytes for i in known]) + allocate(delta - text, [1] * len(opaque))
|
|
124
|
+
ordered = known + opaque
|
|
125
|
+
else:
|
|
126
|
+
shares, ordered = allocate(delta, [i.bytes for i in known]), known
|
|
127
|
+
for i, t in zip(ordered, shares):
|
|
128
|
+
i.tokens, i.how = t, "split"
|
|
129
|
+
return delta - sum(shares)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def attribute(transcript):
|
|
133
|
+
ratio, pairs, default = calibrate(transcript.responses)
|
|
134
|
+
sources = {}
|
|
135
|
+
|
|
136
|
+
def source(key):
|
|
137
|
+
if key not in sources:
|
|
138
|
+
sources[key] = Source(*key)
|
|
139
|
+
return sources[key]
|
|
140
|
+
|
|
141
|
+
prev, ledger = None, {}
|
|
142
|
+
for r in transcript.responses:
|
|
143
|
+
if r.segment_start or prev is None:
|
|
144
|
+
ledger = {}
|
|
145
|
+
rest = _estimated(r.entered, r.context, ratio)
|
|
146
|
+
else:
|
|
147
|
+
ledger = dict(ledger)
|
|
148
|
+
ledger[OUTPUT] = ledger.get(OUTPUT, 0) + prev.output
|
|
149
|
+
source(OUTPUT).entered += prev.output
|
|
150
|
+
rest = _split(r.entered, r.context - prev.context - prev.output, ratio)
|
|
151
|
+
for i in r.entered:
|
|
152
|
+
ledger[i.key] = ledger.get(i.key, 0) + i.tokens
|
|
153
|
+
s = source(i.key)
|
|
154
|
+
s.entered += i.tokens
|
|
155
|
+
s.bytes += i.bytes
|
|
156
|
+
s.chars += i.chars
|
|
157
|
+
s.opaque += i.opaque
|
|
158
|
+
s.how[i.how] = s.how.get(i.how, 0) + 1
|
|
159
|
+
if rest:
|
|
160
|
+
ledger[FLOOR] = ledger.get(FLOOR, 0) + rest
|
|
161
|
+
source(FLOOR).entered += rest
|
|
162
|
+
ledger = {k: v for k, v in ledger.items() if v}
|
|
163
|
+
r.ledger = ledger
|
|
164
|
+
for k, v in ledger.items():
|
|
165
|
+
source(k).cost += v
|
|
166
|
+
source(k).responses += 1
|
|
167
|
+
prev = r
|
|
168
|
+
sources = {k: s for k, s in sources.items() if s.entered or s.cost}
|
|
169
|
+
return Report(transcript.path, transcript.responses, transcript.compactions, transcript.bad_lines,
|
|
170
|
+
ratio, pairs, default, sources)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def analyze(path):
|
|
174
|
+
"""Read one transcript and attribute its context. See `Report`."""
|
|
175
|
+
return attribute(read(path))
|