tokenledger-llm 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.
- tokenledger_llm-0.1.0/LICENSE +21 -0
- tokenledger_llm-0.1.0/PKG-INFO +125 -0
- tokenledger_llm-0.1.0/README.md +113 -0
- tokenledger_llm-0.1.0/pyproject.toml +17 -0
- tokenledger_llm-0.1.0/setup.cfg +4 -0
- tokenledger_llm-0.1.0/tests/test_account.py +51 -0
- tokenledger_llm-0.1.0/tests/test_adapters.py +90 -0
- tokenledger_llm-0.1.0/tokenledger/__init__.py +22 -0
- tokenledger_llm-0.1.0/tokenledger/account.py +87 -0
- tokenledger_llm-0.1.0/tokenledger/adapters.py +177 -0
- tokenledger_llm-0.1.0/tokenledger/parse.py +59 -0
- tokenledger_llm-0.1.0/tokenledger/report.py +84 -0
- tokenledger_llm-0.1.0/tokenledger_llm.egg-info/PKG-INFO +125 -0
- tokenledger_llm-0.1.0/tokenledger_llm.egg-info/SOURCES.txt +15 -0
- tokenledger_llm-0.1.0/tokenledger_llm.egg-info/dependency_links.txt +1 -0
- tokenledger_llm-0.1.0/tokenledger_llm.egg-info/entry_points.txt +2 -0
- tokenledger_llm-0.1.0/tokenledger_llm.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pallav Pokhriyal
|
|
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,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenledger-llm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure the real cost structure of multi-agent / multi-model LLM systems from request logs.
|
|
5
|
+
Author: Pallav Pokhriyal
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,agents,cost,tokens,observability,mlops
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# tokenledger
|
|
14
|
+
|
|
15
|
+
**Measure where the tokens actually go in a multi-agent / multi-model LLM system.**
|
|
16
|
+
|
|
17
|
+
Most teams reason about LLM cost as if it scales with *prompt size*. In a multi-step agent it
|
|
18
|
+
doesn't — it scales with **(context size × number of requests)**, because almost all input is a
|
|
19
|
+
**cache re-read of a growing prefix**, and it is **superlinear in step count**, because each step's
|
|
20
|
+
priming *and* its tool result get re-carried into every later step. `tokenledger` reads your request
|
|
21
|
+
logs and shows you exactly that, so you optimize the right thing.
|
|
22
|
+
|
|
23
|
+
> Status: **v0.1 — early scaffold, feedback very welcome.** Author: Pallav Pokhriyal.
|
|
24
|
+
> Zero dependencies, zero telemetry. It reads logs you already have.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
```bash
|
|
28
|
+
pip install tokenledger-llm # on PyPI; the import name and CLI are both `tokenledger`
|
|
29
|
+
# or from source:
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
```bash
|
|
35
|
+
tokenledger report examples/sample_logs/sample.jsonl
|
|
36
|
+
# or JSON:
|
|
37
|
+
tokenledger report examples/sample_logs/sample.jsonl --json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Example output:
|
|
41
|
+
```
|
|
42
|
+
tokenledger — cost structure report
|
|
43
|
+
==========================================
|
|
44
|
+
requests (steps): 4
|
|
45
|
+
turns: 2
|
|
46
|
+
steps per turn: {1: 3, 2: 1}
|
|
47
|
+
|
|
48
|
+
total input tokens: 89,532
|
|
49
|
+
total output tokens: 505
|
|
50
|
+
|
|
51
|
+
cache-read: 88,432 (98.8% of input)
|
|
52
|
+
priming re-sent: 80,464 (89.9% of input)
|
|
53
|
+
re-carried conversation: 7,968 (8.9% of input)
|
|
54
|
+
|
|
55
|
+
mean prefix / request: 22,383 max prefix: 24,000
|
|
56
|
+
prefix re-read cost: 88,432 tokens
|
|
57
|
+
|
|
58
|
+
takeaway: cost tracks (prefix size x requests), dominated by cache re-reads —
|
|
59
|
+
not prompt size. Cut steps and reset context to move the bill.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Log format
|
|
63
|
+
JSONL, one request (one step within a turn) per line. Required: `turn`, `step`, `input_tokens`.
|
|
64
|
+
Everything else is optional and defaults to 0:
|
|
65
|
+
|
|
66
|
+
| field | meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `turn` / `step` | which turn, and the step index within it |
|
|
69
|
+
| `input_tokens` | total input sent on this request (the context) |
|
|
70
|
+
| `cache_read_tokens` | input served from cache (the re-read prefix) |
|
|
71
|
+
| `priming_tokens` | fixed re-sent block: system prompt, tool schemas |
|
|
72
|
+
| `conversation_tokens` | re-carried prior messages + tool results |
|
|
73
|
+
| `tool_result_tokens` | tokens this step emitted as tool output (carried forward) |
|
|
74
|
+
| `output_tokens` | completion tokens |
|
|
75
|
+
|
|
76
|
+
Have a different log shape? Map it:
|
|
77
|
+
```python
|
|
78
|
+
from tokenledger import load_requests, account
|
|
79
|
+
reqs = load_requests("mylog.jsonl", mapping={"input_tokens": "prompt_tok", "turn": "iteration"})
|
|
80
|
+
print(account(reqs).cache_read_pct)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Provider usage logs (no reshaping)
|
|
84
|
+
|
|
85
|
+
Point it straight at Anthropic or OpenAI usage records:
|
|
86
|
+
```bash
|
|
87
|
+
tokenledger report anthropic_usage.jsonl --format anthropic --turn-key turn --step-key step
|
|
88
|
+
tokenledger report openai_usage.jsonl --format openai --turn-key turn --step-key step
|
|
89
|
+
```
|
|
90
|
+
Each line is one request whose token counts live at top level or under a `usage` key.
|
|
91
|
+
One correctness detail the adapters handle for you:
|
|
92
|
+
|
|
93
|
+
- **Anthropic** reports `usage.input_tokens` as the *fresh* (uncached) input only; cached
|
|
94
|
+
context is reported separately in `cache_read_input_tokens` / `cache_creation_input_tokens`.
|
|
95
|
+
The adapter sums all three so `input_tokens` is the *true* per-request context — taking
|
|
96
|
+
`input_tokens` alone silently undercounts the prefix, which is the whole thing being measured.
|
|
97
|
+
- **OpenAI** reports `usage.prompt_tokens` as the total input already, with the cached portion
|
|
98
|
+
in `prompt_tokens_details.cached_tokens`.
|
|
99
|
+
|
|
100
|
+
If your records don't carry turn/step, omit the keys — each line becomes its own step in a single
|
|
101
|
+
turn (totals and percentages stay correct; the per-turn step structure just goes flat). In Python:
|
|
102
|
+
```python
|
|
103
|
+
from tokenledger import load_provider, account
|
|
104
|
+
reqs = load_provider("anthropic_usage.jsonl", "anthropic", turn_key="turn", step_key="step")
|
|
105
|
+
print(account(reqs).cache_read_pct)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## What-if: which step to cut
|
|
109
|
+
```python
|
|
110
|
+
from tokenledger import load_requests, what_if_remove_step
|
|
111
|
+
reqs = load_requests("examples/sample_logs/sample.jsonl")
|
|
112
|
+
print(what_if_remove_step(reqs, turn=1, step=0)) # tokens saved by removing an early step
|
|
113
|
+
```
|
|
114
|
+
Removing an **early** step saves more than a late one — it drops that step's own cost *and* its
|
|
115
|
+
tool result from every later step. That's the superlinearity, made concrete.
|
|
116
|
+
|
|
117
|
+
## Why it exists
|
|
118
|
+
See [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md) for the measured findings this operationalizes.
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
Issues and PRs welcome — especially log-format adapters for different providers/harnesses. Keep
|
|
122
|
+
contributions genuinely useful; no telemetry, no secrets in sample data (synthetic only).
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# tokenledger
|
|
2
|
+
|
|
3
|
+
**Measure where the tokens actually go in a multi-agent / multi-model LLM system.**
|
|
4
|
+
|
|
5
|
+
Most teams reason about LLM cost as if it scales with *prompt size*. In a multi-step agent it
|
|
6
|
+
doesn't — it scales with **(context size × number of requests)**, because almost all input is a
|
|
7
|
+
**cache re-read of a growing prefix**, and it is **superlinear in step count**, because each step's
|
|
8
|
+
priming *and* its tool result get re-carried into every later step. `tokenledger` reads your request
|
|
9
|
+
logs and shows you exactly that, so you optimize the right thing.
|
|
10
|
+
|
|
11
|
+
> Status: **v0.1 — early scaffold, feedback very welcome.** Author: Pallav Pokhriyal.
|
|
12
|
+
> Zero dependencies, zero telemetry. It reads logs you already have.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
```bash
|
|
16
|
+
pip install tokenledger-llm # on PyPI; the import name and CLI are both `tokenledger`
|
|
17
|
+
# or from source:
|
|
18
|
+
pip install -e .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
```bash
|
|
23
|
+
tokenledger report examples/sample_logs/sample.jsonl
|
|
24
|
+
# or JSON:
|
|
25
|
+
tokenledger report examples/sample_logs/sample.jsonl --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Example output:
|
|
29
|
+
```
|
|
30
|
+
tokenledger — cost structure report
|
|
31
|
+
==========================================
|
|
32
|
+
requests (steps): 4
|
|
33
|
+
turns: 2
|
|
34
|
+
steps per turn: {1: 3, 2: 1}
|
|
35
|
+
|
|
36
|
+
total input tokens: 89,532
|
|
37
|
+
total output tokens: 505
|
|
38
|
+
|
|
39
|
+
cache-read: 88,432 (98.8% of input)
|
|
40
|
+
priming re-sent: 80,464 (89.9% of input)
|
|
41
|
+
re-carried conversation: 7,968 (8.9% of input)
|
|
42
|
+
|
|
43
|
+
mean prefix / request: 22,383 max prefix: 24,000
|
|
44
|
+
prefix re-read cost: 88,432 tokens
|
|
45
|
+
|
|
46
|
+
takeaway: cost tracks (prefix size x requests), dominated by cache re-reads —
|
|
47
|
+
not prompt size. Cut steps and reset context to move the bill.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Log format
|
|
51
|
+
JSONL, one request (one step within a turn) per line. Required: `turn`, `step`, `input_tokens`.
|
|
52
|
+
Everything else is optional and defaults to 0:
|
|
53
|
+
|
|
54
|
+
| field | meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `turn` / `step` | which turn, and the step index within it |
|
|
57
|
+
| `input_tokens` | total input sent on this request (the context) |
|
|
58
|
+
| `cache_read_tokens` | input served from cache (the re-read prefix) |
|
|
59
|
+
| `priming_tokens` | fixed re-sent block: system prompt, tool schemas |
|
|
60
|
+
| `conversation_tokens` | re-carried prior messages + tool results |
|
|
61
|
+
| `tool_result_tokens` | tokens this step emitted as tool output (carried forward) |
|
|
62
|
+
| `output_tokens` | completion tokens |
|
|
63
|
+
|
|
64
|
+
Have a different log shape? Map it:
|
|
65
|
+
```python
|
|
66
|
+
from tokenledger import load_requests, account
|
|
67
|
+
reqs = load_requests("mylog.jsonl", mapping={"input_tokens": "prompt_tok", "turn": "iteration"})
|
|
68
|
+
print(account(reqs).cache_read_pct)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Provider usage logs (no reshaping)
|
|
72
|
+
|
|
73
|
+
Point it straight at Anthropic or OpenAI usage records:
|
|
74
|
+
```bash
|
|
75
|
+
tokenledger report anthropic_usage.jsonl --format anthropic --turn-key turn --step-key step
|
|
76
|
+
tokenledger report openai_usage.jsonl --format openai --turn-key turn --step-key step
|
|
77
|
+
```
|
|
78
|
+
Each line is one request whose token counts live at top level or under a `usage` key.
|
|
79
|
+
One correctness detail the adapters handle for you:
|
|
80
|
+
|
|
81
|
+
- **Anthropic** reports `usage.input_tokens` as the *fresh* (uncached) input only; cached
|
|
82
|
+
context is reported separately in `cache_read_input_tokens` / `cache_creation_input_tokens`.
|
|
83
|
+
The adapter sums all three so `input_tokens` is the *true* per-request context — taking
|
|
84
|
+
`input_tokens` alone silently undercounts the prefix, which is the whole thing being measured.
|
|
85
|
+
- **OpenAI** reports `usage.prompt_tokens` as the total input already, with the cached portion
|
|
86
|
+
in `prompt_tokens_details.cached_tokens`.
|
|
87
|
+
|
|
88
|
+
If your records don't carry turn/step, omit the keys — each line becomes its own step in a single
|
|
89
|
+
turn (totals and percentages stay correct; the per-turn step structure just goes flat). In Python:
|
|
90
|
+
```python
|
|
91
|
+
from tokenledger import load_provider, account
|
|
92
|
+
reqs = load_provider("anthropic_usage.jsonl", "anthropic", turn_key="turn", step_key="step")
|
|
93
|
+
print(account(reqs).cache_read_pct)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## What-if: which step to cut
|
|
97
|
+
```python
|
|
98
|
+
from tokenledger import load_requests, what_if_remove_step
|
|
99
|
+
reqs = load_requests("examples/sample_logs/sample.jsonl")
|
|
100
|
+
print(what_if_remove_step(reqs, turn=1, step=0)) # tokens saved by removing an early step
|
|
101
|
+
```
|
|
102
|
+
Removing an **early** step saves more than a late one — it drops that step's own cost *and* its
|
|
103
|
+
tool result from every later step. That's the superlinearity, made concrete.
|
|
104
|
+
|
|
105
|
+
## Why it exists
|
|
106
|
+
See [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md) for the measured findings this operationalizes.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
Issues and PRs welcome — especially log-format adapters for different providers/harnesses. Keep
|
|
110
|
+
contributions genuinely useful; no telemetry, no secrets in sample data (synthetic only).
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tokenledger-llm"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Measure the real cost structure of multi-agent / multi-model LLM systems from request logs."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Pallav Pokhriyal" }]
|
|
9
|
+
keywords = ["llm", "agents", "cost", "tokens", "observability", "mlops"]
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
tokenledger = "tokenledger.report:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=61"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from tokenledger.account import account, what_if_remove_step
|
|
4
|
+
from tokenledger.parse import Request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestAccount(unittest.TestCase):
|
|
8
|
+
def setUp(self):
|
|
9
|
+
self.reqs = [
|
|
10
|
+
Request(turn=1, step=0, input_tokens=1000, cache_read_tokens=900,
|
|
11
|
+
priming_tokens=800, conversation_tokens=0, tool_result_tokens=100, output_tokens=50),
|
|
12
|
+
Request(turn=1, step=1, input_tokens=1200, cache_read_tokens=1100,
|
|
13
|
+
priming_tokens=800, conversation_tokens=300, tool_result_tokens=100, output_tokens=50),
|
|
14
|
+
Request(turn=2, step=0, input_tokens=1000, cache_read_tokens=900,
|
|
15
|
+
priming_tokens=800, conversation_tokens=0, tool_result_tokens=100, output_tokens=50),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
def test_totals(self):
|
|
19
|
+
m = account(self.reqs)
|
|
20
|
+
self.assertEqual(m.total_input, 3200)
|
|
21
|
+
self.assertEqual(m.total_cache_read, 2900)
|
|
22
|
+
self.assertAlmostEqual(m.cache_read_pct, 2900 / 3200)
|
|
23
|
+
self.assertEqual(m.n_turns, 2)
|
|
24
|
+
self.assertEqual(m.steps_per_turn, {1: 2, 2: 1})
|
|
25
|
+
self.assertEqual(m.priming_total, 2400)
|
|
26
|
+
self.assertAlmostEqual(m.priming_pct, 2400 / 3200)
|
|
27
|
+
|
|
28
|
+
def test_fresh_tokens(self):
|
|
29
|
+
self.assertEqual(self.reqs[0].fresh_tokens, 100)
|
|
30
|
+
|
|
31
|
+
def test_what_if_values(self):
|
|
32
|
+
self.assertEqual(what_if_remove_step(self.reqs, 1, 0), 1000 + 100 * 1)
|
|
33
|
+
self.assertEqual(what_if_remove_step(self.reqs, 1, 1), 1200 + 100 * 0)
|
|
34
|
+
|
|
35
|
+
def test_superlinear_earlier_step_saves_more(self):
|
|
36
|
+
t = [
|
|
37
|
+
Request(turn=9, step=0, input_tokens=500, tool_result_tokens=100),
|
|
38
|
+
Request(turn=9, step=1, input_tokens=500, tool_result_tokens=100),
|
|
39
|
+
Request(turn=9, step=2, input_tokens=500, tool_result_tokens=100),
|
|
40
|
+
]
|
|
41
|
+
self.assertEqual(what_if_remove_step(t, 9, 0), 500 + 100 * 2)
|
|
42
|
+
self.assertEqual(what_if_remove_step(t, 9, 2), 500 + 100 * 0)
|
|
43
|
+
self.assertGreater(what_if_remove_step(t, 9, 0), what_if_remove_step(t, 9, 2))
|
|
44
|
+
|
|
45
|
+
def test_empty_raises(self):
|
|
46
|
+
with self.assertRaises(ValueError):
|
|
47
|
+
account([])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from tokenledger.adapters import (
|
|
4
|
+
from_anthropic_usage,
|
|
5
|
+
from_openai_usage,
|
|
6
|
+
from_records,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestAnthropicAdapter(unittest.TestCase):
|
|
11
|
+
def test_total_input_sums_fresh_plus_cache(self):
|
|
12
|
+
# Anthropic reports fresh input separately from cached input.
|
|
13
|
+
rec = {
|
|
14
|
+
"usage": {
|
|
15
|
+
"input_tokens": 500,
|
|
16
|
+
"cache_creation_input_tokens": 1000,
|
|
17
|
+
"cache_read_input_tokens": 20000,
|
|
18
|
+
"output_tokens": 120,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
r = from_anthropic_usage(rec, turn=1, step=0)
|
|
22
|
+
self.assertEqual(r.input_tokens, 500 + 1000 + 20000) # 21500 total context
|
|
23
|
+
self.assertEqual(r.cache_read_tokens, 20000)
|
|
24
|
+
self.assertEqual(r.output_tokens, 120)
|
|
25
|
+
self.assertEqual(r.fresh_tokens, 1500) # input - cache_read
|
|
26
|
+
|
|
27
|
+
def test_usage_at_top_level(self):
|
|
28
|
+
rec = {"input_tokens": 10, "cache_read_input_tokens": 90, "output_tokens": 5}
|
|
29
|
+
r = from_anthropic_usage(rec, turn=2, step=3)
|
|
30
|
+
self.assertEqual(r.input_tokens, 100)
|
|
31
|
+
self.assertEqual(r.cache_read_tokens, 90)
|
|
32
|
+
self.assertEqual(r.turn, 2)
|
|
33
|
+
self.assertEqual(r.step, 3)
|
|
34
|
+
|
|
35
|
+
def test_missing_cache_fields_default_zero(self):
|
|
36
|
+
r = from_anthropic_usage({"usage": {"input_tokens": 42}}, turn=1, step=0)
|
|
37
|
+
self.assertEqual(r.input_tokens, 42)
|
|
38
|
+
self.assertEqual(r.cache_read_tokens, 0)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestOpenAIAdapter(unittest.TestCase):
|
|
42
|
+
def test_prompt_tokens_is_total(self):
|
|
43
|
+
rec = {
|
|
44
|
+
"usage": {
|
|
45
|
+
"prompt_tokens": 20500,
|
|
46
|
+
"completion_tokens": 120,
|
|
47
|
+
"total_tokens": 20620,
|
|
48
|
+
"prompt_tokens_details": {"cached_tokens": 20000},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
r = from_openai_usage(rec, turn=1, step=0)
|
|
52
|
+
self.assertEqual(r.input_tokens, 20500) # already total, not summed
|
|
53
|
+
self.assertEqual(r.cache_read_tokens, 20000)
|
|
54
|
+
self.assertEqual(r.output_tokens, 120)
|
|
55
|
+
|
|
56
|
+
def test_no_cached_details(self):
|
|
57
|
+
r = from_openai_usage({"usage": {"prompt_tokens": 100, "completion_tokens": 10}}, turn=1, step=0)
|
|
58
|
+
self.assertEqual(r.input_tokens, 100)
|
|
59
|
+
self.assertEqual(r.cache_read_tokens, 0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestFromRecords(unittest.TestCase):
|
|
63
|
+
def test_turn_step_keys(self):
|
|
64
|
+
recs = [
|
|
65
|
+
{"t": 1, "s": 0, "usage": {"prompt_tokens": 100}},
|
|
66
|
+
{"t": 1, "s": 1, "usage": {"prompt_tokens": 200}},
|
|
67
|
+
{"t": 2, "s": 0, "usage": {"prompt_tokens": 150}},
|
|
68
|
+
]
|
|
69
|
+
reqs = from_records(recs, "openai", turn_key="t", step_key="s")
|
|
70
|
+
self.assertEqual([(r.turn, r.step) for r in reqs], [(1, 0), (1, 1), (2, 0)])
|
|
71
|
+
self.assertEqual([r.input_tokens for r in reqs], [100, 200, 150])
|
|
72
|
+
|
|
73
|
+
def test_default_single_turn_sequential_steps(self):
|
|
74
|
+
recs = [{"usage": {"prompt_tokens": 10}}, {"usage": {"prompt_tokens": 20}}]
|
|
75
|
+
reqs = from_records(recs, "openai")
|
|
76
|
+
self.assertEqual([(r.turn, r.step) for r in reqs], [(1, 0), (1, 1)])
|
|
77
|
+
|
|
78
|
+
def test_turn_fn(self):
|
|
79
|
+
recs = [{"sid": "a", "usage": {"prompt_tokens": 10}},
|
|
80
|
+
{"sid": "a", "usage": {"prompt_tokens": 10}}]
|
|
81
|
+
reqs = from_records(recs, "openai", turn_fn=lambda r: 7 if r["sid"] == "a" else 0)
|
|
82
|
+
self.assertEqual([r.turn for r in reqs], [7, 7])
|
|
83
|
+
|
|
84
|
+
def test_unknown_format_raises(self):
|
|
85
|
+
with self.assertRaises(ValueError):
|
|
86
|
+
from_records([], "cohere")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
unittest.main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""tokenledger — measure the real cost structure of multi-agent LLM systems."""
|
|
2
|
+
from .parse import Request, load_requests
|
|
3
|
+
from .account import Metrics, account, what_if_remove_step
|
|
4
|
+
from .adapters import (
|
|
5
|
+
from_anthropic_usage,
|
|
6
|
+
from_openai_usage,
|
|
7
|
+
from_records,
|
|
8
|
+
load_provider,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Request",
|
|
13
|
+
"load_requests",
|
|
14
|
+
"Metrics",
|
|
15
|
+
"account",
|
|
16
|
+
"what_if_remove_step",
|
|
17
|
+
"from_anthropic_usage",
|
|
18
|
+
"from_openai_usage",
|
|
19
|
+
"from_records",
|
|
20
|
+
"load_provider",
|
|
21
|
+
]
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The accounting math: where the tokens actually go.
|
|
2
|
+
|
|
3
|
+
Core claims this quantifies (see docs/METHODOLOGY.md):
|
|
4
|
+
1. input cost tracks (prefix size x number of requests), dominated by cache re-reads;
|
|
5
|
+
2. cost is superlinear in step count, because each step's priming AND its tool
|
|
6
|
+
result are re-carried into every later step of the turn.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
from .parse import Request
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Metrics:
|
|
18
|
+
n_requests: int
|
|
19
|
+
n_turns: int
|
|
20
|
+
steps_per_turn: Dict[int, int]
|
|
21
|
+
total_input: int
|
|
22
|
+
total_output: int
|
|
23
|
+
total_cache_read: int
|
|
24
|
+
cache_read_pct: float
|
|
25
|
+
priming_total: int
|
|
26
|
+
priming_pct: float
|
|
27
|
+
conversation_total: int
|
|
28
|
+
conversation_pct: float
|
|
29
|
+
mean_prefix: float
|
|
30
|
+
max_prefix: int
|
|
31
|
+
prefix_reread_cost: int # tokens spent re-reading already-seen (cached) context
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
return asdict(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pct(part: int, whole: int) -> float:
|
|
38
|
+
return (part / whole) if whole else 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def account(requests: List[Request]) -> Metrics:
|
|
42
|
+
if not requests:
|
|
43
|
+
raise ValueError("no requests to account")
|
|
44
|
+
|
|
45
|
+
total_input = sum(r.input_tokens for r in requests)
|
|
46
|
+
total_output = sum(r.output_tokens for r in requests)
|
|
47
|
+
total_cache = sum(r.cache_read_tokens for r in requests)
|
|
48
|
+
priming_total = sum(r.priming_tokens for r in requests)
|
|
49
|
+
conversation_total = sum(r.conversation_tokens for r in requests)
|
|
50
|
+
|
|
51
|
+
turns: Dict[int, List[Request]] = {}
|
|
52
|
+
for r in requests:
|
|
53
|
+
turns.setdefault(r.turn, []).append(r)
|
|
54
|
+
steps_per_turn = {t: len(rs) for t, rs in sorted(turns.items())}
|
|
55
|
+
|
|
56
|
+
prefixes = [r.input_tokens for r in requests]
|
|
57
|
+
return Metrics(
|
|
58
|
+
n_requests=len(requests),
|
|
59
|
+
n_turns=len(turns),
|
|
60
|
+
steps_per_turn=steps_per_turn,
|
|
61
|
+
total_input=total_input,
|
|
62
|
+
total_output=total_output,
|
|
63
|
+
total_cache_read=total_cache,
|
|
64
|
+
cache_read_pct=_pct(total_cache, total_input),
|
|
65
|
+
priming_total=priming_total,
|
|
66
|
+
priming_pct=_pct(priming_total, total_input),
|
|
67
|
+
conversation_total=conversation_total,
|
|
68
|
+
conversation_pct=_pct(conversation_total, total_input),
|
|
69
|
+
mean_prefix=sum(prefixes) / len(prefixes),
|
|
70
|
+
max_prefix=max(prefixes),
|
|
71
|
+
prefix_reread_cost=total_cache,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def what_if_remove_step(requests: List[Request], turn: int, step: int) -> int:
|
|
76
|
+
"""Estimated input tokens saved by removing (turn, step).
|
|
77
|
+
|
|
78
|
+
Saving = the removed request's own input, PLUS its tool result, which would
|
|
79
|
+
have been re-carried into every later step of the same turn. This is why
|
|
80
|
+
removing an EARLY step saves more than a late one: the superlinear effect.
|
|
81
|
+
"""
|
|
82
|
+
same_turn = [r for r in requests if r.turn == turn]
|
|
83
|
+
target = next((r for r in same_turn if r.step == step), None)
|
|
84
|
+
if target is None:
|
|
85
|
+
raise KeyError(f"no step {step} in turn {turn}")
|
|
86
|
+
later_steps = [r for r in same_turn if r.step > step]
|
|
87
|
+
return target.input_tokens + target.tool_result_tokens * len(later_steps)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Adapters: turn real provider usage logs into normalized Request records.
|
|
2
|
+
|
|
3
|
+
The point of tokenledger is that you measure *your own* system, so it has to
|
|
4
|
+
read the logs you already have. Providers report token usage differently, and
|
|
5
|
+
one difference matters for correctness:
|
|
6
|
+
|
|
7
|
+
* Anthropic Messages API: `usage.input_tokens` is the FRESH (uncached) input
|
|
8
|
+
only. Cached context is reported SEPARATELY in `cache_read_input_tokens`
|
|
9
|
+
and `cache_creation_input_tokens`. So the *total* context sent on a request
|
|
10
|
+
is the sum of all three — a naive reader who takes `input_tokens` alone
|
|
11
|
+
undercounts the prefix, which is exactly the cost this tool is about.
|
|
12
|
+
|
|
13
|
+
* OpenAI Chat Completions: `usage.prompt_tokens` is ALREADY the total input
|
|
14
|
+
(it includes cached tokens); the cached portion is broken out under
|
|
15
|
+
`usage.prompt_tokens_details.cached_tokens`.
|
|
16
|
+
|
|
17
|
+
These adapters normalize both into Request.input_tokens = total context sent.
|
|
18
|
+
|
|
19
|
+
Turn/step structure usually is NOT in a raw usage record. The superlinearity
|
|
20
|
+
metric (what_if_remove_step, steps_per_turn) depends on it, so you supply it:
|
|
21
|
+
pass turn_key/step_key (field names present in each record) or turn_fn/step_fn
|
|
22
|
+
(callables over the record). If you supply neither, every record is treated as
|
|
23
|
+
its own step within a single turn (step = file order), which still gives you
|
|
24
|
+
correct totals, cache-read % and priming %, but a flat step structure.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Callable, Iterable, List, Optional, Union
|
|
31
|
+
|
|
32
|
+
from .parse import Request
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _usage(record: dict, usage_key: Optional[str]) -> dict:
|
|
36
|
+
"""Return the sub-dict holding token counts, or the record itself."""
|
|
37
|
+
if usage_key and isinstance(record.get(usage_key), dict):
|
|
38
|
+
return record[usage_key]
|
|
39
|
+
if usage_key is None and isinstance(record.get("usage"), dict):
|
|
40
|
+
return record["usage"]
|
|
41
|
+
return record
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _int(d: dict, key: str, default: int = 0) -> int:
|
|
45
|
+
v = d.get(key, default)
|
|
46
|
+
return int(v) if v is not None else default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def from_anthropic_usage(
|
|
50
|
+
record: dict, *, turn: int, step: int, label: str = "", usage_key: Optional[str] = None
|
|
51
|
+
) -> Request:
|
|
52
|
+
"""One Anthropic Messages API record -> Request.
|
|
53
|
+
|
|
54
|
+
total input = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
|
|
55
|
+
(because Anthropic's `input_tokens` excludes cached context).
|
|
56
|
+
"""
|
|
57
|
+
u = _usage(record, usage_key)
|
|
58
|
+
fresh = _int(u, "input_tokens")
|
|
59
|
+
cache_creation = _int(u, "cache_creation_input_tokens")
|
|
60
|
+
cache_read = _int(u, "cache_read_input_tokens")
|
|
61
|
+
total_input = fresh + cache_creation + cache_read
|
|
62
|
+
return Request(
|
|
63
|
+
turn=turn,
|
|
64
|
+
step=step,
|
|
65
|
+
input_tokens=total_input,
|
|
66
|
+
cache_read_tokens=cache_read,
|
|
67
|
+
output_tokens=_int(u, "output_tokens"),
|
|
68
|
+
label=label or str(record.get("label", "")),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def from_openai_usage(
|
|
73
|
+
record: dict, *, turn: int, step: int, label: str = "", usage_key: Optional[str] = None
|
|
74
|
+
) -> Request:
|
|
75
|
+
"""One OpenAI Chat Completions record -> Request.
|
|
76
|
+
|
|
77
|
+
`prompt_tokens` is already the total input; the cached portion is in
|
|
78
|
+
`prompt_tokens_details.cached_tokens`.
|
|
79
|
+
"""
|
|
80
|
+
u = _usage(record, usage_key)
|
|
81
|
+
total_input = _int(u, "prompt_tokens")
|
|
82
|
+
details = u.get("prompt_tokens_details") or {}
|
|
83
|
+
cache_read = _int(details, "cached_tokens") if isinstance(details, dict) else 0
|
|
84
|
+
return Request(
|
|
85
|
+
turn=turn,
|
|
86
|
+
step=step,
|
|
87
|
+
input_tokens=total_input,
|
|
88
|
+
cache_read_tokens=cache_read,
|
|
89
|
+
output_tokens=_int(u, "completion_tokens"),
|
|
90
|
+
label=label or str(record.get("label", "")),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_CONVERTERS = {
|
|
95
|
+
"anthropic": from_anthropic_usage,
|
|
96
|
+
"openai": from_openai_usage,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _turn_step_resolver(
|
|
101
|
+
turn_key: Optional[str],
|
|
102
|
+
step_key: Optional[str],
|
|
103
|
+
turn_fn: Optional[Callable[[dict], int]],
|
|
104
|
+
step_fn: Optional[Callable[[dict], int]],
|
|
105
|
+
):
|
|
106
|
+
def resolve(record: dict, idx: int):
|
|
107
|
+
if turn_fn is not None:
|
|
108
|
+
turn = int(turn_fn(record))
|
|
109
|
+
elif turn_key is not None:
|
|
110
|
+
turn = int(record[turn_key])
|
|
111
|
+
else:
|
|
112
|
+
turn = 1
|
|
113
|
+
if step_fn is not None:
|
|
114
|
+
step = int(step_fn(record))
|
|
115
|
+
elif step_key is not None:
|
|
116
|
+
step = int(record[step_key])
|
|
117
|
+
else:
|
|
118
|
+
step = idx
|
|
119
|
+
return turn, step
|
|
120
|
+
|
|
121
|
+
return resolve
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def from_records(
|
|
125
|
+
records: Iterable[dict],
|
|
126
|
+
fmt: str,
|
|
127
|
+
*,
|
|
128
|
+
turn_key: Optional[str] = None,
|
|
129
|
+
step_key: Optional[str] = None,
|
|
130
|
+
turn_fn: Optional[Callable[[dict], int]] = None,
|
|
131
|
+
step_fn: Optional[Callable[[dict], int]] = None,
|
|
132
|
+
usage_key: Optional[str] = None,
|
|
133
|
+
) -> List[Request]:
|
|
134
|
+
"""Convert an iterable of provider usage dicts into Request records."""
|
|
135
|
+
if fmt not in _CONVERTERS:
|
|
136
|
+
raise ValueError(f"unknown format {fmt!r}; expected one of {sorted(_CONVERTERS)}")
|
|
137
|
+
convert = _CONVERTERS[fmt]
|
|
138
|
+
resolve = _turn_step_resolver(turn_key, step_key, turn_fn, step_fn)
|
|
139
|
+
out: List[Request] = []
|
|
140
|
+
for idx, record in enumerate(records):
|
|
141
|
+
turn, step = resolve(record, idx)
|
|
142
|
+
out.append(convert(record, turn=turn, step=step, usage_key=usage_key))
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_provider(
|
|
147
|
+
path: Union[str, Path],
|
|
148
|
+
fmt: str,
|
|
149
|
+
*,
|
|
150
|
+
turn_key: Optional[str] = None,
|
|
151
|
+
step_key: Optional[str] = None,
|
|
152
|
+
turn_fn: Optional[Callable[[dict], int]] = None,
|
|
153
|
+
step_fn: Optional[Callable[[dict], int]] = None,
|
|
154
|
+
usage_key: Optional[str] = None,
|
|
155
|
+
) -> List[Request]:
|
|
156
|
+
"""Load a JSONL usage log in a provider format into Request records.
|
|
157
|
+
|
|
158
|
+
Blank lines and lines starting with '#' are skipped. Each remaining line is
|
|
159
|
+
one JSON object carrying a usage record (either at top level or under a
|
|
160
|
+
`usage` key).
|
|
161
|
+
"""
|
|
162
|
+
records: List[dict] = []
|
|
163
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
164
|
+
for line in f:
|
|
165
|
+
line = line.strip()
|
|
166
|
+
if not line or line.startswith("#"):
|
|
167
|
+
continue
|
|
168
|
+
records.append(json.loads(line))
|
|
169
|
+
return from_records(
|
|
170
|
+
records,
|
|
171
|
+
fmt,
|
|
172
|
+
turn_key=turn_key,
|
|
173
|
+
step_key=step_key,
|
|
174
|
+
turn_fn=turn_fn,
|
|
175
|
+
step_fn=step_fn,
|
|
176
|
+
usage_key=usage_key,
|
|
177
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Ingest request logs into a normalized Request model.
|
|
2
|
+
|
|
3
|
+
A log is JSONL: one JSON object per line, one object per LLM request (one step
|
|
4
|
+
within a turn). Unknown keys are ignored; missing optional fields default to 0.
|
|
5
|
+
Map a provider's own field names with the `mapping` argument.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Request:
|
|
17
|
+
"""One LLM request = one step within a conversation turn."""
|
|
18
|
+
turn: int # which turn (main-loop iteration) this belongs to
|
|
19
|
+
step: int # step index within the turn (0-based)
|
|
20
|
+
input_tokens: int # total input tokens sent on this request (the context)
|
|
21
|
+
cache_read_tokens: int = 0 # input tokens served from cache (re-read prefix)
|
|
22
|
+
priming_tokens: int = 0 # fixed re-sent block: system prompt, tool schemas, agreements
|
|
23
|
+
conversation_tokens: int = 0 # re-carried prior messages + tool results
|
|
24
|
+
tool_result_tokens: int = 0 # tokens this step emitted as tool output (carried forward)
|
|
25
|
+
output_tokens: int = 0
|
|
26
|
+
label: str = ""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def fresh_tokens(self) -> int:
|
|
30
|
+
"""Input tokens that were NOT served from cache on this request."""
|
|
31
|
+
return max(self.input_tokens - self.cache_read_tokens, 0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_FIELDS = {
|
|
35
|
+
"turn", "step", "input_tokens", "cache_read_tokens", "priming_tokens",
|
|
36
|
+
"conversation_tokens", "tool_result_tokens", "output_tokens", "label",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce(obj: dict, mapping: Optional[dict]) -> Request:
|
|
41
|
+
if mapping:
|
|
42
|
+
obj = {ours: obj.get(theirs) for ours, theirs in mapping.items()}
|
|
43
|
+
kwargs = {k: v for k, v in obj.items() if k in _FIELDS and v is not None}
|
|
44
|
+
for required in ("turn", "step", "input_tokens"):
|
|
45
|
+
if required not in kwargs:
|
|
46
|
+
raise ValueError(f"record missing required field '{required}': {obj}")
|
|
47
|
+
return Request(**kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_requests(path: Union[str, Path], mapping: Optional[dict] = None) -> List[Request]:
|
|
51
|
+
"""Load a JSONL request log. Blank lines and lines starting with '#' are skipped."""
|
|
52
|
+
out: List[Request] = []
|
|
53
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line or line.startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
out.append(_coerce(json.loads(line), mapping))
|
|
59
|
+
return out
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""CLI: `tokenledger report <log.jsonl>` → human or JSON cost-structure report."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from .account import Metrics, account
|
|
10
|
+
from .adapters import load_provider
|
|
11
|
+
from .parse import load_requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_report(m: Metrics) -> str:
|
|
15
|
+
lines = [
|
|
16
|
+
"tokenledger — cost structure report",
|
|
17
|
+
"=" * 42,
|
|
18
|
+
f"requests (steps): {m.n_requests}",
|
|
19
|
+
f"turns: {m.n_turns}",
|
|
20
|
+
f"steps per turn: {m.steps_per_turn}",
|
|
21
|
+
"",
|
|
22
|
+
f"total input tokens: {m.total_input:,}",
|
|
23
|
+
f"total output tokens: {m.total_output:,}",
|
|
24
|
+
"",
|
|
25
|
+
f"cache-read: {m.total_cache_read:,} ({m.cache_read_pct:.1%} of input)",
|
|
26
|
+
f"priming re-sent: {m.priming_total:,} ({m.priming_pct:.1%} of input)",
|
|
27
|
+
f"re-carried conversation: {m.conversation_total:,} ({m.conversation_pct:.1%} of input)",
|
|
28
|
+
"",
|
|
29
|
+
f"mean prefix / request: {m.mean_prefix:,.0f} max prefix: {m.max_prefix:,}",
|
|
30
|
+
f"prefix re-read cost: {m.prefix_reread_cost:,} tokens",
|
|
31
|
+
"",
|
|
32
|
+
"takeaway: cost tracks (prefix size x requests), dominated by cache re-reads —",
|
|
33
|
+
"not prompt size. Cut steps and reset context to move the bill.",
|
|
34
|
+
]
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
39
|
+
p = argparse.ArgumentParser(
|
|
40
|
+
prog="tokenledger",
|
|
41
|
+
description="Measure the cost structure of multi-agent LLM systems from request logs.",
|
|
42
|
+
)
|
|
43
|
+
sub = p.add_subparsers(dest="cmd")
|
|
44
|
+
rep = sub.add_parser("report", help="print a cost-structure report for a JSONL log")
|
|
45
|
+
rep.add_argument("logfile", help="path to a JSONL request log")
|
|
46
|
+
rep.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
|
47
|
+
rep.add_argument(
|
|
48
|
+
"--format",
|
|
49
|
+
choices=["native", "anthropic", "openai"],
|
|
50
|
+
default="native",
|
|
51
|
+
help="log format: 'native' tokenledger fields (default), or a provider usage log",
|
|
52
|
+
)
|
|
53
|
+
rep.add_argument("--turn-key", help="record field naming the turn (provider formats)")
|
|
54
|
+
rep.add_argument("--step-key", help="record field naming the step (provider formats)")
|
|
55
|
+
rep.add_argument(
|
|
56
|
+
"--usage-key",
|
|
57
|
+
help="record field holding the usage sub-object (default: 'usage' if present, else top level)",
|
|
58
|
+
)
|
|
59
|
+
args = p.parse_args(argv)
|
|
60
|
+
|
|
61
|
+
if args.cmd != "report":
|
|
62
|
+
p.print_help()
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
if args.format == "native":
|
|
66
|
+
requests = load_requests(args.logfile)
|
|
67
|
+
else:
|
|
68
|
+
requests = load_provider(
|
|
69
|
+
args.logfile,
|
|
70
|
+
args.format,
|
|
71
|
+
turn_key=args.turn_key,
|
|
72
|
+
step_key=args.step_key,
|
|
73
|
+
usage_key=args.usage_key,
|
|
74
|
+
)
|
|
75
|
+
metrics = account(requests)
|
|
76
|
+
if args.json:
|
|
77
|
+
print(json.dumps(metrics.to_dict(), indent=2))
|
|
78
|
+
else:
|
|
79
|
+
print(format_report(metrics))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenledger-llm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure the real cost structure of multi-agent / multi-model LLM systems from request logs.
|
|
5
|
+
Author: Pallav Pokhriyal
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,agents,cost,tokens,observability,mlops
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# tokenledger
|
|
14
|
+
|
|
15
|
+
**Measure where the tokens actually go in a multi-agent / multi-model LLM system.**
|
|
16
|
+
|
|
17
|
+
Most teams reason about LLM cost as if it scales with *prompt size*. In a multi-step agent it
|
|
18
|
+
doesn't — it scales with **(context size × number of requests)**, because almost all input is a
|
|
19
|
+
**cache re-read of a growing prefix**, and it is **superlinear in step count**, because each step's
|
|
20
|
+
priming *and* its tool result get re-carried into every later step. `tokenledger` reads your request
|
|
21
|
+
logs and shows you exactly that, so you optimize the right thing.
|
|
22
|
+
|
|
23
|
+
> Status: **v0.1 — early scaffold, feedback very welcome.** Author: Pallav Pokhriyal.
|
|
24
|
+
> Zero dependencies, zero telemetry. It reads logs you already have.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
```bash
|
|
28
|
+
pip install tokenledger-llm # on PyPI; the import name and CLI are both `tokenledger`
|
|
29
|
+
# or from source:
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
```bash
|
|
35
|
+
tokenledger report examples/sample_logs/sample.jsonl
|
|
36
|
+
# or JSON:
|
|
37
|
+
tokenledger report examples/sample_logs/sample.jsonl --json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Example output:
|
|
41
|
+
```
|
|
42
|
+
tokenledger — cost structure report
|
|
43
|
+
==========================================
|
|
44
|
+
requests (steps): 4
|
|
45
|
+
turns: 2
|
|
46
|
+
steps per turn: {1: 3, 2: 1}
|
|
47
|
+
|
|
48
|
+
total input tokens: 89,532
|
|
49
|
+
total output tokens: 505
|
|
50
|
+
|
|
51
|
+
cache-read: 88,432 (98.8% of input)
|
|
52
|
+
priming re-sent: 80,464 (89.9% of input)
|
|
53
|
+
re-carried conversation: 7,968 (8.9% of input)
|
|
54
|
+
|
|
55
|
+
mean prefix / request: 22,383 max prefix: 24,000
|
|
56
|
+
prefix re-read cost: 88,432 tokens
|
|
57
|
+
|
|
58
|
+
takeaway: cost tracks (prefix size x requests), dominated by cache re-reads —
|
|
59
|
+
not prompt size. Cut steps and reset context to move the bill.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Log format
|
|
63
|
+
JSONL, one request (one step within a turn) per line. Required: `turn`, `step`, `input_tokens`.
|
|
64
|
+
Everything else is optional and defaults to 0:
|
|
65
|
+
|
|
66
|
+
| field | meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `turn` / `step` | which turn, and the step index within it |
|
|
69
|
+
| `input_tokens` | total input sent on this request (the context) |
|
|
70
|
+
| `cache_read_tokens` | input served from cache (the re-read prefix) |
|
|
71
|
+
| `priming_tokens` | fixed re-sent block: system prompt, tool schemas |
|
|
72
|
+
| `conversation_tokens` | re-carried prior messages + tool results |
|
|
73
|
+
| `tool_result_tokens` | tokens this step emitted as tool output (carried forward) |
|
|
74
|
+
| `output_tokens` | completion tokens |
|
|
75
|
+
|
|
76
|
+
Have a different log shape? Map it:
|
|
77
|
+
```python
|
|
78
|
+
from tokenledger import load_requests, account
|
|
79
|
+
reqs = load_requests("mylog.jsonl", mapping={"input_tokens": "prompt_tok", "turn": "iteration"})
|
|
80
|
+
print(account(reqs).cache_read_pct)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Provider usage logs (no reshaping)
|
|
84
|
+
|
|
85
|
+
Point it straight at Anthropic or OpenAI usage records:
|
|
86
|
+
```bash
|
|
87
|
+
tokenledger report anthropic_usage.jsonl --format anthropic --turn-key turn --step-key step
|
|
88
|
+
tokenledger report openai_usage.jsonl --format openai --turn-key turn --step-key step
|
|
89
|
+
```
|
|
90
|
+
Each line is one request whose token counts live at top level or under a `usage` key.
|
|
91
|
+
One correctness detail the adapters handle for you:
|
|
92
|
+
|
|
93
|
+
- **Anthropic** reports `usage.input_tokens` as the *fresh* (uncached) input only; cached
|
|
94
|
+
context is reported separately in `cache_read_input_tokens` / `cache_creation_input_tokens`.
|
|
95
|
+
The adapter sums all three so `input_tokens` is the *true* per-request context — taking
|
|
96
|
+
`input_tokens` alone silently undercounts the prefix, which is the whole thing being measured.
|
|
97
|
+
- **OpenAI** reports `usage.prompt_tokens` as the total input already, with the cached portion
|
|
98
|
+
in `prompt_tokens_details.cached_tokens`.
|
|
99
|
+
|
|
100
|
+
If your records don't carry turn/step, omit the keys — each line becomes its own step in a single
|
|
101
|
+
turn (totals and percentages stay correct; the per-turn step structure just goes flat). In Python:
|
|
102
|
+
```python
|
|
103
|
+
from tokenledger import load_provider, account
|
|
104
|
+
reqs = load_provider("anthropic_usage.jsonl", "anthropic", turn_key="turn", step_key="step")
|
|
105
|
+
print(account(reqs).cache_read_pct)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## What-if: which step to cut
|
|
109
|
+
```python
|
|
110
|
+
from tokenledger import load_requests, what_if_remove_step
|
|
111
|
+
reqs = load_requests("examples/sample_logs/sample.jsonl")
|
|
112
|
+
print(what_if_remove_step(reqs, turn=1, step=0)) # tokens saved by removing an early step
|
|
113
|
+
```
|
|
114
|
+
Removing an **early** step saves more than a late one — it drops that step's own cost *and* its
|
|
115
|
+
tool result from every later step. That's the superlinearity, made concrete.
|
|
116
|
+
|
|
117
|
+
## Why it exists
|
|
118
|
+
See [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md) for the measured findings this operationalizes.
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
Issues and PRs welcome — especially log-format adapters for different providers/harnesses. Keep
|
|
122
|
+
contributions genuinely useful; no telemetry, no secrets in sample data (synthetic only).
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_account.py
|
|
5
|
+
tests/test_adapters.py
|
|
6
|
+
tokenledger/__init__.py
|
|
7
|
+
tokenledger/account.py
|
|
8
|
+
tokenledger/adapters.py
|
|
9
|
+
tokenledger/parse.py
|
|
10
|
+
tokenledger/report.py
|
|
11
|
+
tokenledger_llm.egg-info/PKG-INFO
|
|
12
|
+
tokenledger_llm.egg-info/SOURCES.txt
|
|
13
|
+
tokenledger_llm.egg-info/dependency_links.txt
|
|
14
|
+
tokenledger_llm.egg-info/entry_points.txt
|
|
15
|
+
tokenledger_llm.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tokenledger
|