claude-agent-ledger 0.1.0 → 0.3.0
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.
- package/CHANGELOG.md +55 -12
- package/README.md +137 -57
- package/bin/agent-ledger.ts +59 -14
- package/package.json +1 -1
- package/src/aggregator.ts +37 -12
- package/src/format.ts +290 -22
- package/src/pricing.ts +20 -1
- package/src/types.ts +10 -0
- package/test/aggregator.test.ts +25 -0
- package/test/pricing.test.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to `agent-ledger` will be documented here.
|
|
3
|
+
All notable changes to `claude-agent-ledger` will be documented here.
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.3.0] — 2026-04-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `--summary` flag — single-screen dashboard with shadow cost, top subagent /
|
|
12
|
+
model / day, cache reuse ratio, and **leverage vs $200/mo Max plan**.
|
|
13
|
+
This is the killer view for "why install over /cost?"
|
|
14
|
+
- Auto-derived insights footer on the default table:
|
|
15
|
+
- Top contributor with %
|
|
16
|
+
- 1h cache spend call-out (when ≥5% of total)
|
|
17
|
+
- Cache reuse ratio (read/write) with verdict
|
|
18
|
+
- Monthly $ equivalent + leverage vs Max plan
|
|
19
|
+
- Daily-mode insights: peak day, avg/day, peak/trough variance
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `--by day` polish:
|
|
23
|
+
- Bar fixed to 40-char width so session counts align in their own column
|
|
24
|
+
- Brighter cyan (`\x1b[96m`) bar character
|
|
25
|
+
- Total row separated by horizontal rule, properly aligned
|
|
26
|
+
- Header line now shows period + total + sessions + group count at a glance:
|
|
27
|
+
`agent-ledger week 2026-04-15 → 2026-04-22 · $13,356.27 shadow · 217 sessions · 7 agents`
|
|
28
|
+
|
|
29
|
+
## [0.2.0] — 2026-04-21
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- `--by <subagent|model|day>` flag — group rows by subagent (default), by
|
|
33
|
+
Claude model, or by date. The `day` mode renders an ASCII bar chart of
|
|
34
|
+
daily spend, useful for spotting "expensive Tuesdays."
|
|
35
|
+
- Server-tool pricing: `web_search_requests` and `web_fetch_requests` from
|
|
36
|
+
the `server_tool_use` block are now counted at $0.01/request and surface
|
|
37
|
+
in a footer row when present.
|
|
38
|
+
- ANSI color output, TTY-aware (NO_COLOR / FORCE_COLOR respected).
|
|
39
|
+
- Subagent / model / day labels in cyan
|
|
40
|
+
- Cost amounts color-coded by magnitude (red ≥$1000, yellow ≥$100,
|
|
41
|
+
green ≥$1, dim <$1)
|
|
42
|
+
- Total row bolded
|
|
43
|
+
- New tests for server-tool pricing, `--by model`, and `--by day`
|
|
44
|
+
(22 tests, up from 17).
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- `aggregate(turns, from, to)` accepts an optional 4th arg `group: GroupKey`
|
|
48
|
+
(`'subagent' | 'model' | 'day'`). Default behavior unchanged.
|
|
49
|
+
- `AggregatedRow` now carries `webSearchRequests` and `webFetchRequests`
|
|
50
|
+
counters; `CostBreakdown` adds `serverToolUseCost`.
|
|
51
|
+
|
|
52
|
+
### Not yet
|
|
53
|
+
- Calibration against real Anthropic invoices (need volunteers — see
|
|
54
|
+
`.github/ISSUE_TEMPLATE/`)
|
|
55
|
+
- Cost forecasting (trailing 7-day burn rate)
|
|
56
|
+
- Homebrew tap
|
|
57
|
+
|
|
58
|
+
## [0.1.0] — 2026-04-21
|
|
59
|
+
|
|
60
|
+
Initial public release.
|
|
9
61
|
|
|
10
62
|
### Added
|
|
11
63
|
- JSONL parser with recursive walk of `~/.claude/projects/**`, including nested
|
|
@@ -21,13 +73,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
21
73
|
- Family-prefix fallback for unknown future models (e.g. `claude-opus-5-*`
|
|
22
74
|
resolves to Opus pricing)
|
|
23
75
|
- `<synthetic>` turns (Claude Code context compaction) correctly priced at $0
|
|
24
|
-
|
|
25
|
-
### Not yet
|
|
26
|
-
- `--by model` breakdown
|
|
27
|
-
- Cost forecasting (trailing 7-day burn rate)
|
|
28
|
-
- `server_tool_use` pricing (web_search_requests, web_fetch_requests)
|
|
29
|
-
- Homebrew tap
|
|
30
|
-
|
|
31
|
-
## [0.0.1] — 2026-04-21
|
|
32
|
-
|
|
33
|
-
Initial skeleton, not yet published.
|
|
76
|
+
- Published to npm as `claude-agent-ledger`
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# agent-ledger
|
|
1
|
+
# claude-agent-ledger
|
|
2
2
|
|
|
3
|
-
> See exactly where your Claude Code budget goes — per subagent, per
|
|
3
|
+
> See exactly where your Claude Code budget goes — per subagent, per model, per day.
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/claude-agent-ledger)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
---
|
|
@@ -12,50 +12,104 @@
|
|
|
12
12
|
Claude Code's invoice at month-end tells you one number. It doesn't tell you:
|
|
13
13
|
|
|
14
14
|
- Which of your subagents burned most of it
|
|
15
|
-
- Which
|
|
16
|
-
-
|
|
15
|
+
- Which model (Opus / Sonnet / Haiku) ate the budget
|
|
16
|
+
- Which day of last week was the expensive one
|
|
17
17
|
- Whether your "cheap" Haiku agent is actually quietly expensive
|
|
18
18
|
|
|
19
19
|
If you run **one** Claude Code session, this is fine — you already know.
|
|
20
20
|
If you run **seven** subagents daily (reliability, release, review, iOS factory, docs, ...), you're flying blind.
|
|
21
21
|
|
|
22
|
-
`agent-ledger` reads Claude Code's local session logs and gives you a per-subagent ledger. That's it.
|
|
22
|
+
`claude-agent-ledger` reads Claude Code's local session logs and gives you a per-subagent ledger. That's it.
|
|
23
23
|
|
|
24
|
-
##
|
|
25
|
-
|
|
26
|
-
A single-file CLI. No server. No account. No data leaves your machine.
|
|
24
|
+
## Install
|
|
27
25
|
|
|
28
26
|
```bash
|
|
29
|
-
# Install
|
|
30
27
|
npm install -g claude-agent-ledger
|
|
31
28
|
# or: bun install -g claude-agent-ledger
|
|
32
29
|
|
|
33
|
-
#
|
|
34
|
-
agent-ledger
|
|
30
|
+
agent-ledger week --summary # the dashboard (start here)
|
|
31
|
+
agent-ledger week --by model # which Claude model burned the budget
|
|
32
|
+
agent-ledger week --by day # daily bar chart with peak/avg/variance
|
|
33
|
+
agent-ledger month --md > report.md
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No server. No account. No data leaves your machine.
|
|
37
|
+
|
|
38
|
+
## Why this and not `/cost`?
|
|
39
|
+
|
|
40
|
+
Claude Code's built-in `/cost` shows the **current session** cost. That's it.
|
|
41
|
+
|
|
42
|
+
`claude-agent-ledger` answers questions `/cost` can't:
|
|
43
|
+
|
|
44
|
+
- Which of my **10+ subagents** burned the budget? (`--by model` / default)
|
|
45
|
+
- Which **day** of last week was the expensive one? (`--by day`)
|
|
46
|
+
- What would my Claude Max usage have cost on **pay-as-you-go**? (the "shadow cost" framing)
|
|
47
|
+
- Is my **cache reuse ratio** healthy? (auto-derived insight)
|
|
48
|
+
- What's the **leverage** I'm getting from my $200/mo subscription? (e.g. 250×)
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
agent-ledger week
|
|
50
|
+
Run `agent-ledger week --summary` once and decide.
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
## Example output (real, from the author's machine)
|
|
53
|
+
|
|
54
|
+
### `--summary` (the dashboard)
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
agent-ledger week summary 2026-04-15 → 2026-04-22
|
|
58
|
+
|
|
59
|
+
Shadow cost $13,356.27
|
|
60
|
+
Sessions 217
|
|
61
|
+
Plan reference $200/mo (Max)
|
|
62
|
+
Multiplier 250× vs Max plan
|
|
63
|
+
|
|
64
|
+
Top subagent (main) $12,701.19 (95%)
|
|
65
|
+
Top model claude-opus-4-7 $7,184.45 (54%)
|
|
66
|
+
Peak day 2026-04-15 $3,679.80
|
|
67
|
+
|
|
68
|
+
Cache 1h writes 119.45M tokens · $3583.42
|
|
69
|
+
Cache reads 5.54B tokens · $8162.93
|
|
70
|
+
Cache reuse 38× reads/writes
|
|
71
|
+
Server tools none
|
|
41
72
|
```
|
|
42
73
|
|
|
43
|
-
|
|
74
|
+
### Default — by subagent
|
|
44
75
|
|
|
45
76
|
```
|
|
46
77
|
agent-ledger week · 2026-04-14 → 2026-04-21
|
|
47
78
|
|
|
48
|
-
subagent sess tokens(in/out)
|
|
49
|
-
|
|
50
|
-
(main)
|
|
51
|
-
general-purpose
|
|
52
|
-
Swift Developer 13 4K / 574K
|
|
53
|
-
Market Researcher 10 107K / 74K
|
|
54
|
-
Explore 13 176K / 77K
|
|
55
|
-
zstackws 3 232 / 20K
|
|
56
|
-
claude-code-guide 1 2K / 2K
|
|
57
|
-
|
|
58
|
-
total
|
|
79
|
+
subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
|
|
80
|
+
───────────────────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
(main) 28 514K / 15.87M 1h:116.10M 5m: 0 r:5229.48M $12524.97
|
|
82
|
+
general-purpose 148 113K / 955K 1h: 0 5m: 20.67M r: 183.94M $599.97
|
|
83
|
+
Swift Developer 13 4K / 574K 1h: 0 5m: 1.61M r: 47.55M $28.93
|
|
84
|
+
Market Researcher 10 107K / 74K 1h: 0 5m: 863K r: 8.61M $7.26
|
|
85
|
+
Explore 13 176K / 77K 1h: 0 5m: 2.60M r: 22.38M $6.47
|
|
86
|
+
zstackws 3 232 / 20K 1h: 0 5m: 829K r: 3.52M $4.47
|
|
87
|
+
claude-code-guide 1 2K / 2K 1h: 0 5m: 56K r: 223K $0.08
|
|
88
|
+
───────────────────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
total 216 916K / 17.57M 1h:116.10M 5m: 26.62M r:5495.71M $13172.14
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `--by model`
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
|
|
96
|
+
───────────────────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
claude-opus-4-7 16 59K / 8.35M 1h: 61.15M 5m: 2.32M r:2974.59M $6967.29
|
|
98
|
+
claude-opus-4-6 127 544K / 8.25M 1h: 55.02M 5m: 13.30M r:2422.18M $6160.46
|
|
99
|
+
claude-sonnet-4-6 65 135K / 946K 1h: 0 5m: 8.81M r: 98.12M $77.07
|
|
100
|
+
claude-haiku-4-5 13 178K / 67K 1h: 0 5m: 2.19M r: 21.57M $4.33
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `--by day` (ASCII bar chart)
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
2026-04-15 $3749.84 ████████████████████████████████████ (18 sess)
|
|
107
|
+
2026-04-16 $2492.99 ████████████████████████ (175 sess)
|
|
108
|
+
2026-04-17 $2111.63 ████████████████████ (9 sess)
|
|
109
|
+
2026-04-18 $1840.02 ██████████████████ (18 sess)
|
|
110
|
+
2026-04-19 $644.97 ██████ (5 sess)
|
|
111
|
+
2026-04-20 $1095.58 ███████████ (6 sess)
|
|
112
|
+
2026-04-21 $1273.55 ████████████ (9 sess)
|
|
59
113
|
```
|
|
60
114
|
|
|
61
115
|
Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#shadow-cost-vs-actual-bill).
|
|
@@ -64,24 +118,27 @@ Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#sh
|
|
|
64
118
|
|
|
65
119
|
The `cost` column shows what you **would** pay at Anthropic's pay-as-you-go API rates.
|
|
66
120
|
|
|
67
|
-
If you're on a fixed-price subscription (Claude Pro / Max / Claude Code plan), your actual bill is capped — the shadow cost is what the same usage **would** have cost through the raw API.
|
|
121
|
+
If you're on a fixed-price subscription (Claude Pro / Max / Claude Code plan), your actual bill is capped — the shadow cost is what the same usage **would** have cost through the raw API. Useful for:
|
|
68
122
|
|
|
69
123
|
- Understanding where time-and-token budget goes, regardless of how you pay
|
|
70
124
|
- Spotting runaway agents (a 10× spike is still a 10× spike, capped or not)
|
|
71
125
|
- Deciding when to downshift to Haiku or up to Opus per task
|
|
126
|
+
- Justifying a Max subscription internally ("our shadow cost is $13K/wk, Max is $200/mo — that's a 65× discount")
|
|
72
127
|
|
|
73
|
-
If you're on pay-as-you-go, the `cost` column is roughly your bill (±5% depending on tier / geography).
|
|
128
|
+
If you're on pay-as-you-go, the `cost` column is roughly your bill (±5–10% depending on tier / geography / `server_tool_use` calibration).
|
|
74
129
|
|
|
75
130
|
## How it works
|
|
76
131
|
|
|
77
132
|
Claude Code writes every conversation turn to `~/.claude/projects/<encoded-path>/*.jsonl`.
|
|
78
|
-
Each turn records input/output tokens, cache tokens (with TTL split),
|
|
79
|
-
|
|
80
|
-
`.meta.json`
|
|
133
|
+
Each turn records input/output tokens, cache tokens (with TTL split), the model used,
|
|
134
|
+
and any `server_tool_use` (web_search / web_fetch) requests. Subagent invocations live
|
|
135
|
+
under `<session>/subagents/agent-*.jsonl`, with a sidecar `.meta.json` carrying the real
|
|
136
|
+
subagent name (e.g. `Reality Checker`, `ios-factory`).
|
|
81
137
|
|
|
82
|
-
`agent-ledger` walks the tree, resolves subagent types from the sidecar metadata,
|
|
83
|
-
aggregates by
|
|
84
|
-
anywhere — the tool never touches the network
|
|
138
|
+
`claude-agent-ledger` walks the tree, resolves subagent types from the sidecar metadata,
|
|
139
|
+
and aggregates by your chosen key (subagent, model, or day). Pricing is applied locally
|
|
140
|
+
from `src/pricing.ts`. Nothing is sent anywhere — the tool never touches the network
|
|
141
|
+
after install.
|
|
85
142
|
|
|
86
143
|
### Cache accounting
|
|
87
144
|
|
|
@@ -93,47 +150,71 @@ Anthropic's prompt caching has two TTLs in current logs:
|
|
|
93
150
|
| `ephemeral_1h_input_tokens` | 2.00× base input | 1-hour TTL (long-lived sessions) |
|
|
94
151
|
| `cache_read_input_tokens` | 0.10× base input | Read price (90% discount, TTL-agnostic) |
|
|
95
152
|
|
|
96
|
-
Naive trackers lump these together as "input" and either overstate (by counting all creation as 1.25×) or understate (by ignoring the 1-hour writes entirely). `agent-ledger` breaks them out explicitly — see `src/pricing.ts`.
|
|
153
|
+
Naive trackers lump these together as "input" and either overstate (by counting all creation as 1.25×) or understate (by ignoring the 1-hour writes entirely). `claude-agent-ledger` breaks them out explicitly — see `src/pricing.ts`.
|
|
154
|
+
|
|
155
|
+
### Server tool pricing
|
|
156
|
+
|
|
157
|
+
`web_search_requests` and `web_fetch_requests` are counted at $0.01/request and shown in a footer when present:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
server tools: web_search ×7, web_fetch ×0 ($0.07)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
If Anthropic's actual rate differs (or web_fetch has its own price), open an issue.
|
|
164
|
+
|
|
165
|
+
## Flags
|
|
166
|
+
|
|
167
|
+
| Flag | What it does |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `--by <subagent\|model\|day>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
|
|
170
|
+
| `--md` | Markdown table output (good for committing or sharing) |
|
|
171
|
+
| `--json` | Raw JSON output (pipe into jq) |
|
|
172
|
+
| `--plan pro\|max` | Hide dollar columns; show token utilization only |
|
|
173
|
+
|
|
174
|
+
| Env | What it does |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `NO_COLOR=1` | Disable ANSI colors |
|
|
177
|
+
| `FORCE_COLOR=1` | Force ANSI colors when piping |
|
|
97
178
|
|
|
98
179
|
## What it's not
|
|
99
180
|
|
|
100
181
|
- Not a real-time dashboard (post-hoc log reader)
|
|
101
|
-
- Not a tracker for direct Anthropic API calls (those logs live
|
|
182
|
+
- Not a tracker for direct Anthropic API calls (those logs live server-side)
|
|
102
183
|
- Not a cost-limiter or kill-switch (read-only, by design)
|
|
103
184
|
- Not affiliated with Anthropic
|
|
104
185
|
|
|
105
186
|
## Who this is for
|
|
106
187
|
|
|
107
188
|
- You run ≥ 2 Claude Code subagents and genuinely don't know the per-agent split
|
|
108
|
-
- You're
|
|
189
|
+
- You're on Claude Max and want to know what your "free" usage is worth
|
|
109
190
|
- You're building AI Agent systems and want observability primitives, not vendor dashboards
|
|
110
191
|
|
|
111
192
|
## Status
|
|
112
193
|
|
|
113
|
-
|
|
114
|
-
Pricing
|
|
115
|
-
may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier
|
|
116
|
-
server_tool_use
|
|
194
|
+
`v0.2.0` shipped 2026-04-21. Validated on ~200K turns of real Claude Code logs.
|
|
195
|
+
Pricing matches Anthropic published rates as of 2026-04-21. Shadow cost values
|
|
196
|
+
may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier
|
|
197
|
+
bumps, `server_tool_use` calibration). Calibration PRs welcome — see issue
|
|
198
|
+
templates.
|
|
117
199
|
|
|
118
200
|
- [x] Log parser (JSONL streaming, recursive subagent discovery)
|
|
119
|
-
- [x] Per-subagent aggregation
|
|
120
|
-
- [x]
|
|
121
|
-
- [x]
|
|
201
|
+
- [x] Per-subagent aggregation
|
|
202
|
+
- [x] Per-model aggregation (`--by model`)
|
|
203
|
+
- [x] Per-day aggregation with bar chart (`--by day`)
|
|
204
|
+
- [x] Terminal table + Markdown + JSON output
|
|
205
|
+
- [x] ANSI color, TTY-aware
|
|
122
206
|
- [x] Cache 1h/5m TTL split pricing
|
|
123
|
-
- [
|
|
124
|
-
- [
|
|
125
|
-
- [ ] Per-model breakdown (`agent-ledger week --by model`)
|
|
207
|
+
- [x] `--plan pro|max` to mask shadow cost
|
|
208
|
+
- [x] `server_tool_use` pricing (web_search, web_fetch)
|
|
126
209
|
- [ ] Cost forecasting (trailing 7-day burn rate)
|
|
127
|
-
- [ ]
|
|
210
|
+
- [ ] Calibration against real Anthropic invoices
|
|
128
211
|
- [ ] Homebrew tap
|
|
129
212
|
|
|
130
|
-
First tagged release: **2026-05-05**.
|
|
131
|
-
|
|
132
213
|
## Install from source
|
|
133
214
|
|
|
134
215
|
```bash
|
|
135
|
-
git clone https://github.com/XJM-free/agent-ledger.git
|
|
136
|
-
cd agent-ledger
|
|
216
|
+
git clone https://github.com/XJM-free/claude-agent-ledger.git
|
|
217
|
+
cd claude-agent-ledger
|
|
137
218
|
bun install
|
|
138
219
|
bun link
|
|
139
220
|
agent-ledger today
|
|
@@ -142,15 +223,14 @@ agent-ledger today
|
|
|
142
223
|
## Development
|
|
143
224
|
|
|
144
225
|
```bash
|
|
145
|
-
bun test
|
|
226
|
+
bun test # 22 pass, 0 fail
|
|
146
227
|
bun run typecheck
|
|
147
228
|
```
|
|
148
229
|
|
|
149
230
|
## Author
|
|
150
231
|
|
|
151
232
|
Built by [@XJM-free](https://jiexiang.dev) while running seven production Claude Code
|
|
152
|
-
subagents for infrastructure work. More context
|
|
153
|
-
Claude Code](https://jiexiang.dev).
|
|
233
|
+
subagents for infrastructure work. More context coming on the blog.
|
|
154
234
|
|
|
155
235
|
## License
|
|
156
236
|
|
package/bin/agent-ledger.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { aggregate } from '../src/aggregator.ts';
|
|
3
|
-
import { formatMarkdown, formatTable } from '../src/format.ts';
|
|
2
|
+
import { aggregate, type GroupKey } from '../src/aggregator.ts';
|
|
3
|
+
import { formatMarkdown, formatSummary, formatTable, formatTrend } from '../src/format.ts';
|
|
4
4
|
import { parseAll } from '../src/parser.ts';
|
|
5
|
-
import type { LedgerReport } from '../src/types.ts';
|
|
5
|
+
import type { LedgerReport, SessionTurn } from '../src/types.ts';
|
|
6
6
|
|
|
7
7
|
type Period = 'today' | 'week' | 'month';
|
|
8
8
|
type Plan = 'payg' | 'pro' | 'max';
|
|
@@ -12,6 +12,8 @@ interface Args {
|
|
|
12
12
|
markdown: boolean;
|
|
13
13
|
json: boolean;
|
|
14
14
|
plan: Plan;
|
|
15
|
+
by: GroupKey;
|
|
16
|
+
summary: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function periodRange(period: Period): { from: Date; to: Date } {
|
|
@@ -33,16 +35,24 @@ function usage(): never {
|
|
|
33
35
|
console.error(`Usage: agent-ledger <today|week|month> [flags]
|
|
34
36
|
|
|
35
37
|
Flags:
|
|
36
|
-
--
|
|
37
|
-
|
|
38
|
-
--
|
|
39
|
-
|
|
38
|
+
--summary One-screen dashboard: total + top subagent + top
|
|
39
|
+
model + peak day + cache reuse + leverage vs $200/mo plan
|
|
40
|
+
--by <subagent|model|day> Group rows by subagent (default), model, or day
|
|
41
|
+
"day" renders as a daily ASCII bar chart
|
|
42
|
+
--md Markdown table output (good for sharing / commits)
|
|
43
|
+
--json Raw JSON output (for piping into jq)
|
|
44
|
+
--plan pro|max Suppress dollar columns (token utilization only)
|
|
45
|
+
|
|
46
|
+
Environment:
|
|
47
|
+
NO_COLOR=1 Disable ANSI colors
|
|
48
|
+
FORCE_COLOR=1 Force colors when piping
|
|
40
49
|
|
|
41
50
|
Examples:
|
|
42
|
-
agent-ledger
|
|
43
|
-
agent-ledger week --
|
|
44
|
-
agent-ledger
|
|
45
|
-
agent-ledger
|
|
51
|
+
agent-ledger week --summary # the dashboard
|
|
52
|
+
agent-ledger week --by day # daily bar chart
|
|
53
|
+
agent-ledger week --by model # which Claude model burned the budget
|
|
54
|
+
agent-ledger month --md > month.md
|
|
55
|
+
agent-ledger week --json | jq '.total.cost.totalCost'
|
|
46
56
|
`);
|
|
47
57
|
process.exit(1);
|
|
48
58
|
}
|
|
@@ -52,6 +62,7 @@ function parseArgs(argv: string[]): Args {
|
|
|
52
62
|
if (!period || !['today', 'week', 'month'].includes(period)) usage();
|
|
53
63
|
|
|
54
64
|
let plan: Plan = 'payg';
|
|
65
|
+
let by: GroupKey = 'subagent';
|
|
55
66
|
for (let i = 0; i < rest.length; i++) {
|
|
56
67
|
const flag = rest[i];
|
|
57
68
|
if (flag === '--plan') {
|
|
@@ -62,6 +73,14 @@ function parseArgs(argv: string[]): Args {
|
|
|
62
73
|
} else {
|
|
63
74
|
usage();
|
|
64
75
|
}
|
|
76
|
+
} else if (flag === '--by') {
|
|
77
|
+
const next = rest[i + 1];
|
|
78
|
+
if (next === 'subagent' || next === 'model' || next === 'day') {
|
|
79
|
+
by = next;
|
|
80
|
+
i++;
|
|
81
|
+
} else {
|
|
82
|
+
usage();
|
|
83
|
+
}
|
|
65
84
|
}
|
|
66
85
|
}
|
|
67
86
|
return {
|
|
@@ -69,19 +88,20 @@ function parseArgs(argv: string[]): Args {
|
|
|
69
88
|
markdown: rest.includes('--md'),
|
|
70
89
|
json: rest.includes('--json'),
|
|
71
90
|
plan,
|
|
91
|
+
by,
|
|
92
|
+
summary: rest.includes('--summary'),
|
|
72
93
|
};
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
function planMask(report: LedgerReport, plan: Plan): LedgerReport {
|
|
76
97
|
if (plan === 'payg') return report;
|
|
77
|
-
// On Pro/Max the per-call dollar number isn't what you pay, so hide it.
|
|
78
|
-
// Keep tokens visible — those are the real utilization signal.
|
|
79
98
|
const zero = () => ({
|
|
80
99
|
inputCost: 0,
|
|
81
100
|
outputCost: 0,
|
|
82
101
|
cacheCreation5mCost: 0,
|
|
83
102
|
cacheCreation1hCost: 0,
|
|
84
103
|
cacheReadCost: 0,
|
|
104
|
+
serverToolUseCost: 0,
|
|
85
105
|
totalCost: 0,
|
|
86
106
|
});
|
|
87
107
|
return {
|
|
@@ -91,10 +111,33 @@ function planMask(report: LedgerReport, plan: Plan): LedgerReport {
|
|
|
91
111
|
};
|
|
92
112
|
}
|
|
93
113
|
|
|
114
|
+
// Materialize once so --summary can re-aggregate by 3 dimensions cheaply.
|
|
115
|
+
async function collectTurns(opts: { from: Date; to: Date }): Promise<SessionTurn[]> {
|
|
116
|
+
const out: SessionTurn[] = [];
|
|
117
|
+
for await (const t of parseAll(opts)) out.push(t);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function* iterTurns(turns: SessionTurn[]): AsyncGenerator<SessionTurn> {
|
|
122
|
+
for (const t of turns) yield t;
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
async function main(): Promise<void> {
|
|
95
126
|
const args = parseArgs(process.argv.slice(2));
|
|
96
127
|
const { from, to } = periodRange(args.period);
|
|
97
|
-
|
|
128
|
+
const turns = await collectTurns({ from, to });
|
|
129
|
+
|
|
130
|
+
if (args.summary) {
|
|
131
|
+
const [bySub, byMod, byDay] = await Promise.all([
|
|
132
|
+
aggregate(iterTurns(turns), from, to, 'subagent'),
|
|
133
|
+
aggregate(iterTurns(turns), from, to, 'model'),
|
|
134
|
+
aggregate(iterTurns(turns), from, to, 'day'),
|
|
135
|
+
]);
|
|
136
|
+
console.log(formatSummary(bySub, byMod, byDay, args.period));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let report = await aggregate(iterTurns(turns), from, to, args.by);
|
|
98
141
|
report = planMask(report, args.plan);
|
|
99
142
|
|
|
100
143
|
let output: string;
|
|
@@ -102,6 +145,8 @@ async function main(): Promise<void> {
|
|
|
102
145
|
output = JSON.stringify(report, null, 2);
|
|
103
146
|
} else if (args.markdown) {
|
|
104
147
|
output = formatMarkdown(report, args.period);
|
|
148
|
+
} else if (args.by === 'day') {
|
|
149
|
+
output = formatTrend(report, args.period);
|
|
105
150
|
} else {
|
|
106
151
|
output = formatTable(report, args.period);
|
|
107
152
|
}
|
package/package.json
CHANGED
package/src/aggregator.ts
CHANGED
|
@@ -2,22 +2,28 @@ import { costFor } from './pricing.ts';
|
|
|
2
2
|
import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
|
|
3
3
|
|
|
4
4
|
const MAIN_AGENT_LABEL = '(main)';
|
|
5
|
+
const UNKNOWN_MODEL_LABEL = '(no-model)';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
export type GroupKey = 'subagent' | 'model' | 'day';
|
|
8
|
+
|
|
9
|
+
function emptyRow(label: string): AggregatedRow {
|
|
7
10
|
return {
|
|
8
|
-
subagent,
|
|
11
|
+
subagent: label,
|
|
9
12
|
sessionCount: 0,
|
|
10
13
|
inputTokens: 0,
|
|
11
14
|
outputTokens: 0,
|
|
12
15
|
cacheCreation5mTokens: 0,
|
|
13
16
|
cacheCreation1hTokens: 0,
|
|
14
17
|
cacheReadTokens: 0,
|
|
18
|
+
webSearchRequests: 0,
|
|
19
|
+
webFetchRequests: 0,
|
|
15
20
|
cost: {
|
|
16
21
|
inputCost: 0,
|
|
17
22
|
outputCost: 0,
|
|
18
23
|
cacheCreation5mCost: 0,
|
|
19
24
|
cacheCreation1hCost: 0,
|
|
20
25
|
cacheReadCost: 0,
|
|
26
|
+
serverToolUseCost: 0,
|
|
21
27
|
totalCost: 0,
|
|
22
28
|
},
|
|
23
29
|
};
|
|
@@ -35,19 +41,32 @@ function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
|
|
|
35
41
|
return { fiveMin: usage.cache_creation_input_tokens ?? 0, oneHour: 0 };
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
function keyOf(turn: SessionTurn, group: GroupKey): string {
|
|
45
|
+
if (group === 'model') return turn.model ?? UNKNOWN_MODEL_LABEL;
|
|
46
|
+
if (group === 'day') return turn.timestamp.slice(0, 10);
|
|
47
|
+
return turn.subagentType ?? MAIN_AGENT_LABEL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sortKey(group: GroupKey): (a: AggregatedRow, b: AggregatedRow) => number {
|
|
51
|
+
// For day groupings users want chronological; otherwise descend by cost.
|
|
52
|
+
if (group === 'day') return (a, b) => (a.subagent < b.subagent ? -1 : a.subagent > b.subagent ? 1 : 0);
|
|
53
|
+
return (a, b) => b.cost.totalCost - a.cost.totalCost;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
export async function aggregate(
|
|
39
57
|
turns: AsyncIterable<SessionTurn>,
|
|
40
58
|
from: Date,
|
|
41
59
|
to: Date,
|
|
60
|
+
group: GroupKey = 'subagent',
|
|
42
61
|
): Promise<LedgerReport> {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
62
|
+
const rowsByKey = new Map<string, AggregatedRow>();
|
|
63
|
+
const sessionsByKey = new Map<string, Set<string>>();
|
|
45
64
|
|
|
46
65
|
for await (const turn of turns) {
|
|
47
66
|
if (turn.type !== 'assistant' || !turn.usage) continue;
|
|
48
67
|
|
|
49
|
-
const
|
|
50
|
-
const row =
|
|
68
|
+
const key = keyOf(turn, group);
|
|
69
|
+
const row = rowsByKey.get(key) ?? emptyRow(key);
|
|
51
70
|
|
|
52
71
|
const cost = costFor(turn.usage, turn.model);
|
|
53
72
|
const { fiveMin, oneHour } = splitCache(turn);
|
|
@@ -57,25 +76,28 @@ export async function aggregate(
|
|
|
57
76
|
row.cacheCreation5mTokens += fiveMin;
|
|
58
77
|
row.cacheCreation1hTokens += oneHour;
|
|
59
78
|
row.cacheReadTokens += turn.usage.cache_read_input_tokens ?? 0;
|
|
79
|
+
row.webSearchRequests += turn.usage.server_tool_use?.web_search_requests ?? 0;
|
|
80
|
+
row.webFetchRequests += turn.usage.server_tool_use?.web_fetch_requests ?? 0;
|
|
60
81
|
row.cost.inputCost += cost.inputCost;
|
|
61
82
|
row.cost.outputCost += cost.outputCost;
|
|
62
83
|
row.cost.cacheCreation5mCost += cost.cacheCreation5mCost;
|
|
63
84
|
row.cost.cacheCreation1hCost += cost.cacheCreation1hCost;
|
|
64
85
|
row.cost.cacheReadCost += cost.cacheReadCost;
|
|
86
|
+
row.cost.serverToolUseCost += cost.serverToolUseCost;
|
|
65
87
|
row.cost.totalCost += cost.totalCost;
|
|
66
88
|
|
|
67
|
-
const sessions =
|
|
89
|
+
const sessions = sessionsByKey.get(key) ?? new Set<string>();
|
|
68
90
|
sessions.add(turn.sessionId);
|
|
69
|
-
|
|
91
|
+
sessionsByKey.set(key, sessions);
|
|
70
92
|
|
|
71
|
-
|
|
93
|
+
rowsByKey.set(key, row);
|
|
72
94
|
}
|
|
73
95
|
|
|
74
|
-
for (const [
|
|
75
|
-
row.sessionCount =
|
|
96
|
+
for (const [key, row] of rowsByKey) {
|
|
97
|
+
row.sessionCount = sessionsByKey.get(key)?.size ?? 0;
|
|
76
98
|
}
|
|
77
99
|
|
|
78
|
-
const rows = [...
|
|
100
|
+
const rows = [...rowsByKey.values()].sort(sortKey(group));
|
|
79
101
|
const total = rows.reduce<AggregatedRow>((acc, row) => {
|
|
80
102
|
acc.sessionCount += row.sessionCount;
|
|
81
103
|
acc.inputTokens += row.inputTokens;
|
|
@@ -83,11 +105,14 @@ export async function aggregate(
|
|
|
83
105
|
acc.cacheCreation5mTokens += row.cacheCreation5mTokens;
|
|
84
106
|
acc.cacheCreation1hTokens += row.cacheCreation1hTokens;
|
|
85
107
|
acc.cacheReadTokens += row.cacheReadTokens;
|
|
108
|
+
acc.webSearchRequests += row.webSearchRequests;
|
|
109
|
+
acc.webFetchRequests += row.webFetchRequests;
|
|
86
110
|
acc.cost.inputCost += row.cost.inputCost;
|
|
87
111
|
acc.cost.outputCost += row.cost.outputCost;
|
|
88
112
|
acc.cost.cacheCreation5mCost += row.cost.cacheCreation5mCost;
|
|
89
113
|
acc.cost.cacheCreation1hCost += row.cost.cacheCreation1hCost;
|
|
90
114
|
acc.cost.cacheReadCost += row.cost.cacheReadCost;
|
|
115
|
+
acc.cost.serverToolUseCost += row.cost.serverToolUseCost;
|
|
91
116
|
acc.cost.totalCost += row.cost.totalCost;
|
|
92
117
|
return acc;
|
|
93
118
|
}, emptyRow('total'));
|
package/src/format.ts
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import type { AggregatedRow, LedgerReport } from './types.ts';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const PAD_LABEL = 22;
|
|
4
|
+
const BAR_WIDTH = 40;
|
|
5
|
+
const PLAN_MAX_PRICE = 200; // monthly USD reference for shadow-cost leverage
|
|
6
|
+
|
|
7
|
+
// Color helpers — TTY-aware, NO_COLOR-respecting (https://no-color.org/).
|
|
8
|
+
const USE_COLOR = (() => {
|
|
9
|
+
if (process.env.NO_COLOR) return false;
|
|
10
|
+
if (process.env.FORCE_COLOR) return true;
|
|
11
|
+
return Boolean(process.stdout.isTTY);
|
|
12
|
+
})();
|
|
13
|
+
|
|
14
|
+
const c = {
|
|
15
|
+
dim: USE_COLOR ? '\x1b[2m' : '',
|
|
16
|
+
bold: USE_COLOR ? '\x1b[1m' : '',
|
|
17
|
+
green: USE_COLOR ? '\x1b[32m' : '',
|
|
18
|
+
cyan: USE_COLOR ? '\x1b[36m' : '',
|
|
19
|
+
cyanBright: USE_COLOR ? '\x1b[96m' : '',
|
|
20
|
+
yellow: USE_COLOR ? '\x1b[33m' : '',
|
|
21
|
+
red: USE_COLOR ? '\x1b[31m' : '',
|
|
22
|
+
magenta: USE_COLOR ? '\x1b[35m' : '',
|
|
23
|
+
gray: USE_COLOR ? '\x1b[90m' : '',
|
|
24
|
+
reset: USE_COLOR ? '\x1b[0m' : '',
|
|
25
|
+
};
|
|
4
26
|
|
|
5
27
|
function fmtTokens(n: number): string {
|
|
28
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
6
29
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
7
30
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
8
31
|
return String(n);
|
|
@@ -12,43 +35,279 @@ function fmtUsd(n: number): string {
|
|
|
12
35
|
return `$${n.toFixed(2)}`;
|
|
13
36
|
}
|
|
14
37
|
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
38
|
+
function fmtUsdComma(n: number): string {
|
|
39
|
+
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function colorCost(n: number, padded: string): string {
|
|
43
|
+
if (!USE_COLOR) return padded;
|
|
44
|
+
if (n >= 1000) return `${c.bold}${c.red}${padded}${c.reset}`;
|
|
45
|
+
if (n >= 100) return `${c.yellow}${padded}${c.reset}`;
|
|
46
|
+
if (n >= 1) return `${c.green}${padded}${c.reset}`;
|
|
47
|
+
return `${c.dim}${padded}${c.reset}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pct(part: number, whole: number): string {
|
|
51
|
+
if (whole === 0) return '0%';
|
|
52
|
+
return `${((part / whole) * 100).toFixed(0)}%`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatRow(row: AggregatedRow, isTotal = false): string {
|
|
56
|
+
const labelText = row.subagent.padEnd(PAD_LABEL);
|
|
57
|
+
const label = isTotal
|
|
58
|
+
? `${c.bold}${labelText}${c.reset}`
|
|
59
|
+
: `${c.cyan}${labelText}${c.reset}`;
|
|
60
|
+
const sessions = `${c.gray}${String(row.sessionCount).padStart(4)}${c.reset}`;
|
|
18
61
|
const io = `${fmtTokens(row.inputTokens)} / ${fmtTokens(row.outputTokens)}`.padStart(18);
|
|
19
62
|
const cached1h = fmtTokens(row.cacheCreation1hTokens).padStart(7);
|
|
20
63
|
const cached5m = fmtTokens(row.cacheCreation5mTokens).padStart(7);
|
|
21
64
|
const cacheRead = fmtTokens(row.cacheReadTokens).padStart(8);
|
|
22
|
-
const
|
|
23
|
-
|
|
65
|
+
const costStr = fmtUsd(row.cost.totalCost).padStart(9);
|
|
66
|
+
const cost = colorCost(row.cost.totalCost, costStr);
|
|
67
|
+
return ` ${label}${sessions} ${io} ${c.dim}1h:${c.reset}${cached1h} ${c.dim}5m:${c.reset}${cached5m} ${c.dim}r:${c.reset}${cacheRead} ${cost}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Auto-derived insights. The whole point of installing the tool over /cost.
|
|
71
|
+
function deriveInsights(report: LedgerReport): string[] {
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
const total = report.total.cost.totalCost;
|
|
74
|
+
if (total === 0) return lines;
|
|
75
|
+
|
|
76
|
+
// Top contributor with %
|
|
77
|
+
const top = report.rows[0];
|
|
78
|
+
if (top && top.cost.totalCost > 0) {
|
|
79
|
+
lines.push(
|
|
80
|
+
` ${c.cyanBright}⚡${c.reset} top: ${c.cyan}${top.subagent}${c.reset} drives ${c.bold}${pct(top.cost.totalCost, total)}${c.reset} of spend ${c.dim}(${fmtUsd(top.cost.totalCost)} of ${fmtUsd(total)})${c.reset}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 1h cache call-out (if non-trivial)
|
|
85
|
+
const oneHourCost =
|
|
86
|
+
report.total.cost.cacheCreation1hCost ?? 0;
|
|
87
|
+
if (oneHourCost >= total * 0.05) {
|
|
88
|
+
lines.push(
|
|
89
|
+
` ${c.yellow}💾${c.reset} 1h cache writes: ${fmtTokens(report.total.cacheCreation1hTokens)} tokens · ${fmtUsd(oneHourCost)} ${c.dim}(${pct(oneHourCost, total)} of total)${c.reset}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Cache reuse ratio — read vs write
|
|
94
|
+
const writeTokens =
|
|
95
|
+
report.total.cacheCreation1hTokens + report.total.cacheCreation5mTokens;
|
|
96
|
+
if (writeTokens > 0) {
|
|
97
|
+
const ratio = report.total.cacheReadTokens / writeTokens;
|
|
98
|
+
const verdict =
|
|
99
|
+
ratio >= 20
|
|
100
|
+
? `${c.green}excellent reuse${c.reset}`
|
|
101
|
+
: ratio >= 5
|
|
102
|
+
? `${c.yellow}OK reuse${c.reset}`
|
|
103
|
+
: `${c.red}low reuse${c.reset}`;
|
|
104
|
+
lines.push(
|
|
105
|
+
` ${c.cyanBright}📥${c.reset} cache reuse: ${fmtTokens(report.total.cacheReadTokens)} reads / ${fmtTokens(writeTokens)} writes = ${c.bold}${ratio.toFixed(0)}×${c.reset} ${c.dim}(${verdict}${c.dim})${c.reset}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Plan leverage
|
|
110
|
+
const dayCount =
|
|
111
|
+
Math.max(
|
|
112
|
+
1,
|
|
113
|
+
Math.round(
|
|
114
|
+
(report.period.to.getTime() - report.period.from.getTime()) /
|
|
115
|
+
(1000 * 60 * 60 * 24),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
const monthlyEquivalent = (total / dayCount) * 30;
|
|
119
|
+
const leverage = monthlyEquivalent / PLAN_MAX_PRICE;
|
|
120
|
+
if (leverage >= 1) {
|
|
121
|
+
lines.push(
|
|
122
|
+
` ${c.magenta}💸${c.reset} ${fmtUsdComma(monthlyEquivalent)}/mo equivalent ${c.dim}vs $${PLAN_MAX_PRICE}/mo plan = ${c.reset}${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}leverage${c.reset}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines;
|
|
24
127
|
}
|
|
25
128
|
|
|
26
129
|
export function formatTable(report: LedgerReport, label: string): string {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'
|
|
33
|
-
|
|
34
|
-
|
|
130
|
+
const summaryLine =
|
|
131
|
+
`${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
|
|
132
|
+
` ${c.dim}·${c.reset} ` +
|
|
133
|
+
`${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
|
|
134
|
+
` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions` +
|
|
135
|
+
` ${c.dim}·${c.reset} ${report.rows.length} ${label.includes('day') ? 'days' : label.includes('model') ? 'models' : 'agents'}`;
|
|
136
|
+
|
|
137
|
+
const header = `${c.bold}agent-ledger ${label}${c.reset} ${summaryLine}`;
|
|
138
|
+
|
|
139
|
+
const colsRaw =
|
|
140
|
+
' ' +
|
|
141
|
+
'subagent'.padEnd(PAD_LABEL) +
|
|
142
|
+
'sess'.padStart(4) +
|
|
143
|
+
' ' +
|
|
144
|
+
'tokens(in/out)'.padStart(18) +
|
|
145
|
+
' ' +
|
|
146
|
+
'cache tokens (1h/5m/read)'.padStart(32) +
|
|
147
|
+
' ' +
|
|
35
148
|
'cost'.padStart(9);
|
|
149
|
+
const cols = `${c.gray}${colsRaw}${c.reset}`;
|
|
36
150
|
|
|
37
|
-
const
|
|
151
|
+
const sepRaw = ' ' + '─'.repeat(PAD_LABEL + 4 + 2 + 18 + 2 + 32 + 2 + 9);
|
|
152
|
+
const sep = `${c.gray}${sepRaw}${c.reset}`;
|
|
38
153
|
|
|
39
|
-
|
|
154
|
+
const lines = [
|
|
40
155
|
header,
|
|
41
156
|
'',
|
|
42
157
|
cols,
|
|
43
158
|
sep,
|
|
44
|
-
...report.rows.map(formatRow),
|
|
159
|
+
...report.rows.map((r) => formatRow(r)),
|
|
45
160
|
sep,
|
|
46
|
-
formatRow({ ...report.total, subagent: 'total' }),
|
|
47
|
-
]
|
|
161
|
+
formatRow({ ...report.total, subagent: 'total' }, true),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
// Server tool footer (only when present, since most rows have 0)
|
|
165
|
+
if (report.total.webSearchRequests + report.total.webFetchRequests > 0) {
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(
|
|
168
|
+
` ${c.gray}server tools:${c.reset} ` +
|
|
169
|
+
`web_search ×${report.total.webSearchRequests}, ` +
|
|
170
|
+
`web_fetch ×${report.total.webFetchRequests} ` +
|
|
171
|
+
`(${fmtUsd(report.total.cost.serverToolUseCost)})`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Insights
|
|
176
|
+
const insights = deriveInsights(report);
|
|
177
|
+
if (insights.length) {
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push(...insights);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Daily trend chart with fixed-width bars.
|
|
186
|
+
export function formatTrend(report: LedgerReport, label: string): string {
|
|
187
|
+
const max = report.rows.reduce((m, r) => Math.max(m, r.cost.totalCost), 0);
|
|
188
|
+
|
|
189
|
+
const summaryLine =
|
|
190
|
+
`${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
|
|
191
|
+
` ${c.dim}·${c.reset} ` +
|
|
192
|
+
`${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
|
|
193
|
+
` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions across ${report.rows.length} days`;
|
|
194
|
+
|
|
195
|
+
const header = `${c.bold}agent-ledger ${label} (daily)${c.reset} ${summaryLine}`;
|
|
196
|
+
|
|
197
|
+
const lines = [header, ''];
|
|
198
|
+
for (const row of report.rows) {
|
|
199
|
+
const bars = Math.round((row.cost.totalCost / Math.max(max, 0.01)) * BAR_WIDTH);
|
|
200
|
+
const barFilled = '█'.repeat(bars);
|
|
201
|
+
const barPadding = ' '.repeat(BAR_WIDTH - bars);
|
|
202
|
+
const bar = `${USE_COLOR ? c.cyanBright : ''}${barFilled}${USE_COLOR ? c.reset : ''}${barPadding}`;
|
|
203
|
+
const dateLabel = `${c.gray}${row.subagent}${c.reset}`;
|
|
204
|
+
const cost = colorCost(row.cost.totalCost, fmtUsd(row.cost.totalCost).padStart(10));
|
|
205
|
+
const sessionsRaw = `${row.sessionCount} sess`;
|
|
206
|
+
const sessions = `${c.dim}· ${sessionsRaw}${c.reset}`;
|
|
207
|
+
lines.push(` ${dateLabel} ${cost} ${bar} ${sessions}`);
|
|
208
|
+
}
|
|
209
|
+
const sepWidth = 12 + 2 + 10 + 2 + BAR_WIDTH + 2 + 10;
|
|
210
|
+
lines.push(` ${c.gray}${'─'.repeat(sepWidth)}${c.reset}`);
|
|
211
|
+
const totalLabel = `${c.bold}total${c.reset}`.padEnd(12 + (USE_COLOR ? c.bold.length + c.reset.length : 0));
|
|
212
|
+
const totalCost = colorCost(
|
|
213
|
+
report.total.cost.totalCost,
|
|
214
|
+
fmtUsd(report.total.cost.totalCost).padStart(10),
|
|
215
|
+
);
|
|
216
|
+
lines.push(` ${totalLabel} ${totalCost}`);
|
|
217
|
+
|
|
218
|
+
// Daily-mode insights
|
|
219
|
+
const insights = deriveDailyInsights(report);
|
|
220
|
+
if (insights.length) {
|
|
221
|
+
lines.push('');
|
|
222
|
+
lines.push(...insights);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return lines.join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function deriveDailyInsights(report: LedgerReport): string[] {
|
|
229
|
+
if (report.rows.length === 0) return [];
|
|
230
|
+
const lines: string[] = [];
|
|
231
|
+
const peak = [...report.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0]!;
|
|
232
|
+
const trough = [...report.rows]
|
|
233
|
+
.filter((r) => r.cost.totalCost > 0)
|
|
234
|
+
.sort((a, b) => a.cost.totalCost - b.cost.totalCost)[0];
|
|
235
|
+
const avg = report.total.cost.totalCost / report.rows.length;
|
|
236
|
+
|
|
237
|
+
lines.push(
|
|
238
|
+
` ${c.cyanBright}⚡${c.reset} peak: ${c.cyan}${peak.subagent}${c.reset} ${c.bold}${fmtUsd(peak.cost.totalCost)}${c.reset} ${c.dim}(${peak.sessionCount} sess)${c.reset}`,
|
|
239
|
+
);
|
|
240
|
+
lines.push(
|
|
241
|
+
` ${c.gray}📊${c.reset} avg/day: ${fmtUsd(avg)}`,
|
|
242
|
+
);
|
|
243
|
+
if (trough && trough.cost.totalCost > 0) {
|
|
244
|
+
const variance = peak.cost.totalCost / trough.cost.totalCost;
|
|
245
|
+
if (variance >= 3) {
|
|
246
|
+
lines.push(
|
|
247
|
+
` ${c.yellow}🌪${c.reset} variance: ${c.bold}${variance.toFixed(0)}×${c.reset} ${c.dim}between peak (${peak.subagent}) and trough (${trough.subagent})${c.reset}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Single-screen dashboard. Aggregates the same period across 3 dimensions.
|
|
255
|
+
export function formatSummary(
|
|
256
|
+
bySubagent: LedgerReport,
|
|
257
|
+
byModel: LedgerReport,
|
|
258
|
+
byDay: LedgerReport,
|
|
259
|
+
label: string,
|
|
260
|
+
): string {
|
|
261
|
+
const total = bySubagent.total.cost.totalCost;
|
|
262
|
+
const dayCount = Math.max(1, byDay.rows.length);
|
|
263
|
+
const monthlyEquivalent = (total / dayCount) * 30;
|
|
264
|
+
const leverage = monthlyEquivalent / PLAN_MAX_PRICE;
|
|
265
|
+
|
|
266
|
+
const top = (rows: AggregatedRow[]) => rows[0];
|
|
267
|
+
const tSub = top(bySubagent.rows);
|
|
268
|
+
const tMod = top(byModel.rows);
|
|
269
|
+
const tDay = [...byDay.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0];
|
|
270
|
+
|
|
271
|
+
const k = (label: string, value: string) =>
|
|
272
|
+
` ${c.gray}${label.padEnd(20)}${c.reset} ${value}`;
|
|
273
|
+
const dollar = (n: number) => `${c.bold}${fmtUsdComma(n)}${c.reset}`;
|
|
274
|
+
|
|
275
|
+
const writeTokens =
|
|
276
|
+
bySubagent.total.cacheCreation1hTokens + bySubagent.total.cacheCreation5mTokens;
|
|
277
|
+
const reuseRatio = writeTokens > 0 ? bySubagent.total.cacheReadTokens / writeTokens : 0;
|
|
278
|
+
|
|
279
|
+
const lines = [
|
|
280
|
+
`${c.bold}agent-ledger ${label} summary${c.reset} ${c.gray}${bySubagent.period.from.toISOString().slice(0, 10)} → ${bySubagent.period.to.toISOString().slice(0, 10)}${c.reset}`,
|
|
281
|
+
'',
|
|
282
|
+
k('Shadow cost', dollar(total)),
|
|
283
|
+
k('Sessions', `${bySubagent.total.sessionCount}`),
|
|
284
|
+
k('Plan reference', `$${PLAN_MAX_PRICE}/mo (Max)`),
|
|
285
|
+
k('Multiplier', `${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}vs Max plan${c.reset}`),
|
|
286
|
+
'',
|
|
287
|
+
k('Top subagent', tSub ? `${c.cyan}${tSub.subagent}${c.reset} ${dollar(tSub.cost.totalCost)} ${c.dim}(${pct(tSub.cost.totalCost, total)})${c.reset}` : '—'),
|
|
288
|
+
k('Top model', tMod ? `${c.cyan}${tMod.subagent}${c.reset} ${dollar(tMod.cost.totalCost)} ${c.dim}(${pct(tMod.cost.totalCost, total)})${c.reset}` : '—'),
|
|
289
|
+
k('Peak day', tDay ? `${c.cyan}${tDay.subagent}${c.reset} ${dollar(tDay.cost.totalCost)}` : '—'),
|
|
290
|
+
'',
|
|
291
|
+
k('Cache 1h writes', `${fmtTokens(bySubagent.total.cacheCreation1hTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheCreation1hCost)}`),
|
|
292
|
+
k('Cache reads', `${fmtTokens(bySubagent.total.cacheReadTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheReadCost)}`),
|
|
293
|
+
k(
|
|
294
|
+
'Cache reuse',
|
|
295
|
+
writeTokens > 0
|
|
296
|
+
? `${c.bold}${reuseRatio.toFixed(0)}×${c.reset} ${c.dim}reads/writes${c.reset}`
|
|
297
|
+
: 'n/a',
|
|
298
|
+
),
|
|
299
|
+
k(
|
|
300
|
+
'Server tools',
|
|
301
|
+
bySubagent.total.webSearchRequests + bySubagent.total.webFetchRequests > 0
|
|
302
|
+
? `web_search ×${bySubagent.total.webSearchRequests}, web_fetch ×${bySubagent.total.webFetchRequests} (${fmtUsd(bySubagent.total.cost.serverToolUseCost)})`
|
|
303
|
+
: `${c.dim}none${c.reset}`,
|
|
304
|
+
),
|
|
305
|
+
];
|
|
306
|
+
return lines.join('\n');
|
|
48
307
|
}
|
|
49
308
|
|
|
50
309
|
export function formatMarkdown(report: LedgerReport, label: string): string {
|
|
51
|
-
const header = '|
|
|
310
|
+
const header = '| group | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
|
|
52
311
|
const sep = '|---|---:|---:|---:|---:|---:|---:|---:|';
|
|
53
312
|
const rows = report.rows.map(
|
|
54
313
|
(r) =>
|
|
@@ -66,7 +325,7 @@ export function formatMarkdown(report: LedgerReport, label: string): string {
|
|
|
66
325
|
report.total.cacheReadTokens,
|
|
67
326
|
)} | **${fmtUsd(report.total.cost.totalCost)}** |`;
|
|
68
327
|
|
|
69
|
-
|
|
328
|
+
const lines = [
|
|
70
329
|
`# agent-ledger · ${label}`,
|
|
71
330
|
`_${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}_`,
|
|
72
331
|
'',
|
|
@@ -74,5 +333,14 @@ export function formatMarkdown(report: LedgerReport, label: string): string {
|
|
|
74
333
|
sep,
|
|
75
334
|
...rows,
|
|
76
335
|
totalRow,
|
|
77
|
-
]
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
if (report.total.webSearchRequests + report.total.webFetchRequests > 0) {
|
|
339
|
+
lines.push('');
|
|
340
|
+
lines.push(
|
|
341
|
+
`_Server tools: web_search ×${report.total.webSearchRequests}, web_fetch ×${report.total.webFetchRequests} (${fmtUsd(report.total.cost.serverToolUseCost)})_`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return lines.join('\n');
|
|
78
346
|
}
|
package/src/pricing.ts
CHANGED
|
@@ -30,6 +30,12 @@ const CACHE_5M_MULTIPLIER = 1.25;
|
|
|
30
30
|
const CACHE_1H_MULTIPLIER = 2.0;
|
|
31
31
|
const CACHE_READ_MULTIPLIER = 0.1;
|
|
32
32
|
|
|
33
|
+
// Anthropic server-tool pricing (https://www.anthropic.com/pricing as of 2026-04):
|
|
34
|
+
// Web search: $10 per 1,000 requests = $0.01 / request
|
|
35
|
+
// Web fetch: treated identically here pending Anthropic split (file an issue if wrong)
|
|
36
|
+
const WEB_SEARCH_PRICE_PER_REQUEST = 0.01;
|
|
37
|
+
const WEB_FETCH_PRICE_PER_REQUEST = 0.01;
|
|
38
|
+
|
|
33
39
|
function resolveModel(model: ModelId | undefined): { input: number; output: number } {
|
|
34
40
|
if (!model) return { input: 0, output: 0 };
|
|
35
41
|
if (FREE_MODELS.has(model)) return { input: 0, output: 0 };
|
|
@@ -77,13 +83,26 @@ export function costFor(usage: TokenUsage, model: ModelId | undefined): CostBrea
|
|
|
77
83
|
const cacheCreation1hCost = perMillion(oneHour, rate.input * CACHE_1H_MULTIPLIER);
|
|
78
84
|
const cacheReadCost = perMillion(cacheRead, rate.input * CACHE_READ_MULTIPLIER);
|
|
79
85
|
|
|
86
|
+
const webSearchReqs = usage.server_tool_use?.web_search_requests ?? 0;
|
|
87
|
+
const webFetchReqs = usage.server_tool_use?.web_fetch_requests ?? 0;
|
|
88
|
+
const serverToolUseCost =
|
|
89
|
+
webSearchReqs * WEB_SEARCH_PRICE_PER_REQUEST +
|
|
90
|
+
webFetchReqs * WEB_FETCH_PRICE_PER_REQUEST;
|
|
91
|
+
|
|
80
92
|
return {
|
|
81
93
|
inputCost,
|
|
82
94
|
outputCost,
|
|
83
95
|
cacheCreation5mCost,
|
|
84
96
|
cacheCreation1hCost,
|
|
85
97
|
cacheReadCost,
|
|
86
|
-
|
|
98
|
+
serverToolUseCost,
|
|
99
|
+
totalCost:
|
|
100
|
+
inputCost +
|
|
101
|
+
outputCost +
|
|
102
|
+
cacheCreation5mCost +
|
|
103
|
+
cacheCreation1hCost +
|
|
104
|
+
cacheReadCost +
|
|
105
|
+
serverToolUseCost,
|
|
87
106
|
};
|
|
88
107
|
}
|
|
89
108
|
|
package/src/types.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface CacheCreationDetail {
|
|
|
11
11
|
ephemeral_1h_input_tokens?: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface ServerToolUse {
|
|
15
|
+
web_search_requests?: number;
|
|
16
|
+
web_fetch_requests?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
export interface TokenUsage {
|
|
15
20
|
input_tokens: number;
|
|
16
21
|
output_tokens: number;
|
|
@@ -19,6 +24,8 @@ export interface TokenUsage {
|
|
|
19
24
|
cache_creation_input_tokens?: number;
|
|
20
25
|
cache_read_input_tokens?: number;
|
|
21
26
|
cache_creation?: CacheCreationDetail;
|
|
27
|
+
// Anthropic server-side tool use (web_search, web_fetch). Priced per request, not per token.
|
|
28
|
+
server_tool_use?: ServerToolUse;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export interface SessionTurn {
|
|
@@ -37,6 +44,7 @@ export interface CostBreakdown {
|
|
|
37
44
|
cacheCreation5mCost: number;
|
|
38
45
|
cacheCreation1hCost: number;
|
|
39
46
|
cacheReadCost: number;
|
|
47
|
+
serverToolUseCost: number;
|
|
40
48
|
totalCost: number;
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -48,6 +56,8 @@ export interface AggregatedRow {
|
|
|
48
56
|
cacheCreation5mTokens: number;
|
|
49
57
|
cacheCreation1hTokens: number;
|
|
50
58
|
cacheReadTokens: number;
|
|
59
|
+
webSearchRequests: number;
|
|
60
|
+
webFetchRequests: number;
|
|
51
61
|
cost: CostBreakdown;
|
|
52
62
|
}
|
|
53
63
|
|
package/test/aggregator.test.ts
CHANGED
|
@@ -47,4 +47,29 @@ describe('aggregate()', () => {
|
|
|
47
47
|
const sumCost = report.rows.reduce((s, r) => s + r.cost.totalCost, 0);
|
|
48
48
|
expect(report.total.cost.totalCost).toBeCloseTo(sumCost, 6);
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
test('--by model groups by model id', async () => {
|
|
52
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
53
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
54
|
+
const report = await aggregate(parseFile(FIXTURE), from, to, 'model');
|
|
55
|
+
|
|
56
|
+
// Fixture has assistant turns with claude-opus-4-7 model
|
|
57
|
+
const labels = report.rows.map((r) => r.subagent);
|
|
58
|
+
expect(labels.some((l) => l.includes('claude-') || l === '(no-model)')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('--by day groups by ISO date and sorts chronologically', async () => {
|
|
62
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
63
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
64
|
+
const report = await aggregate(parseFile(FIXTURE), from, to, 'day');
|
|
65
|
+
|
|
66
|
+
// Each row label should look like a date YYYY-MM-DD
|
|
67
|
+
for (const row of report.rows) {
|
|
68
|
+
expect(row.subagent).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
69
|
+
}
|
|
70
|
+
// Ascending date order
|
|
71
|
+
for (let i = 1; i < report.rows.length; i++) {
|
|
72
|
+
expect(report.rows[i - 1]!.subagent <= report.rows[i]!.subagent).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
50
75
|
});
|
package/test/pricing.test.ts
CHANGED
|
@@ -107,4 +107,38 @@ describe('costFor()', () => {
|
|
|
107
107
|
expect(isKnownModel('something-entirely-unknown')).toBe(false);
|
|
108
108
|
expect(isKnownModel(undefined)).toBe(false);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
test('server_tool_use: web_search at $0.01/req', () => {
|
|
112
|
+
const cost = costFor(
|
|
113
|
+
{
|
|
114
|
+
input_tokens: 0,
|
|
115
|
+
output_tokens: 0,
|
|
116
|
+
server_tool_use: { web_search_requests: 100 },
|
|
117
|
+
},
|
|
118
|
+
'claude-opus-4-7',
|
|
119
|
+
);
|
|
120
|
+
// 100 × $0.01 = $1.00
|
|
121
|
+
expect(cost.serverToolUseCost).toBeCloseTo(1.0, 6);
|
|
122
|
+
expect(cost.totalCost).toBeCloseTo(1.0, 6);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('server_tool_use: web_fetch counted, summed with web_search', () => {
|
|
126
|
+
const cost = costFor(
|
|
127
|
+
{
|
|
128
|
+
input_tokens: 0,
|
|
129
|
+
output_tokens: 0,
|
|
130
|
+
server_tool_use: { web_search_requests: 50, web_fetch_requests: 50 },
|
|
131
|
+
},
|
|
132
|
+
'claude-opus-4-7',
|
|
133
|
+
);
|
|
134
|
+
expect(cost.serverToolUseCost).toBeCloseTo(1.0, 6);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('server_tool_use absent → no cost', () => {
|
|
138
|
+
const cost = costFor(
|
|
139
|
+
{ input_tokens: 1000, output_tokens: 1000 },
|
|
140
|
+
'claude-opus-4-7',
|
|
141
|
+
);
|
|
142
|
+
expect(cost.serverToolUseCost).toBe(0);
|
|
143
|
+
});
|
|
110
144
|
});
|