claude-agent-ledger 0.1.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/.github/ISSUE_TEMPLATE/bug.md +35 -0
- package/.github/ISSUE_TEMPLATE/model-pricing.md +29 -0
- package/.github/pull_request_template.md +19 -0
- package/.github/workflows/ci.yml +19 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +42 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/bin/agent-ledger.ts +115 -0
- package/bun.lock +24 -0
- package/package.json +36 -0
- package/src/aggregator.ts +96 -0
- package/src/format.ts +78 -0
- package/src/parser.ts +134 -0
- package/src/pricing.ts +95 -0
- package/src/types.ts +58 -0
- package/test/aggregator.test.ts +50 -0
- package/test/fixtures/projects/proj-a/session-abc/subagents/agent-deadbeef.jsonl +2 -0
- package/test/fixtures/projects/proj-a/session-abc/subagents/agent-deadbeef.meta.json +1 -0
- package/test/fixtures/projects/proj-a/session-abc.jsonl +2 -0
- package/test/fixtures/sample.jsonl +6 -0
- package/test/parser.test.ts +55 -0
- package/test/pricing.test.ts +110 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Bug report
|
|
3
|
+
about: Report incorrect output, crashes, or wrong attribution
|
|
4
|
+
title: ''
|
|
5
|
+
labels: bug
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What happened
|
|
9
|
+
|
|
10
|
+
<!-- What did agent-ledger do? What did you expect it to do instead? -->
|
|
11
|
+
|
|
12
|
+
## How to reproduce
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
agent-ledger <your-exact-command>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Output (redact sensitive paths / subagent names)
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
<paste the output here>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Your setup
|
|
25
|
+
|
|
26
|
+
- OS: <!-- macOS 15.3 / Ubuntu 22.04 / ... -->
|
|
27
|
+
- Bun version: <!-- `bun --version` -->
|
|
28
|
+
- agent-ledger version: <!-- `agent-ledger --version` (pre-alpha: use commit SHA) -->
|
|
29
|
+
- Claude Code version: <!-- `claude --version` -->
|
|
30
|
+
|
|
31
|
+
## If you're on pay-as-you-go, the calibration ask
|
|
32
|
+
|
|
33
|
+
If you compare `agent-ledger month` against a real Anthropic invoice and
|
|
34
|
+
they differ by > 10%, I want to hear about it. Even one line: "agent-ledger
|
|
35
|
+
says $X, invoice says $Y" is incredibly useful. Thanks.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Model pricing gap
|
|
3
|
+
about: A model ID shows $0 in your ledger — we're missing it from the price table
|
|
4
|
+
title: 'pricing: add <model-id>'
|
|
5
|
+
labels: pricing
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Model ID
|
|
9
|
+
|
|
10
|
+
<!-- e.g. claude-opus-4-8-20260301 -->
|
|
11
|
+
|
|
12
|
+
## Anthropic's published rates
|
|
13
|
+
|
|
14
|
+
- Input: $<X> / 1M tokens
|
|
15
|
+
- Output: $<Y> / 1M tokens
|
|
16
|
+
- Link to Anthropic pricing page (with date):
|
|
17
|
+
|
|
18
|
+
## Where you saw it
|
|
19
|
+
|
|
20
|
+
Sample from your log (redact the content, keep the model field):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{"message":{"model":"<model-id>", "usage":{"input_tokens":..., "output_tokens":...}}}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Optional: PR
|
|
27
|
+
|
|
28
|
+
If you want to open the PR, the pricing table lives in `src/pricing.ts`. Add a
|
|
29
|
+
matching test in `test/pricing.test.ts`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## What this PR does
|
|
2
|
+
|
|
3
|
+
<!-- One or two sentences. -->
|
|
4
|
+
|
|
5
|
+
## Checklist
|
|
6
|
+
|
|
7
|
+
- [ ] `bun run typecheck` passes
|
|
8
|
+
- [ ] `bun test` passes
|
|
9
|
+
- [ ] If you touched `src/pricing.ts`, there's a matching test in
|
|
10
|
+
`test/pricing.test.ts`
|
|
11
|
+
- [ ] If you touched `src/parser.ts`, the change doesn't break the nested
|
|
12
|
+
`subagents/` walk (see `test/parser.test.ts`)
|
|
13
|
+
- [ ] No runtime dependencies added (this tool intentionally has none)
|
|
14
|
+
|
|
15
|
+
## Log snippet (if relevant)
|
|
16
|
+
|
|
17
|
+
<!-- If the change is about handling some real log shape, include a redacted
|
|
18
|
+
example of what it looks like. Helps me reason about whether other tools
|
|
19
|
+
produce the same shape. -->
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: oven-sh/setup-bun@v2
|
|
15
|
+
with:
|
|
16
|
+
bun-version: latest
|
|
17
|
+
- run: bun install --frozen-lockfile
|
|
18
|
+
- run: bun run typecheck
|
|
19
|
+
- run: bun test
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `agent-ledger` will be documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- JSONL parser with recursive walk of `~/.claude/projects/**`, including nested
|
|
12
|
+
`subagents/` directories
|
|
13
|
+
- Per-subagent attribution via sidecar `agent-<hash>.meta.json` files
|
|
14
|
+
- Pricing with cache TTL split: `ephemeral_5m_input_tokens` (1.25×) vs
|
|
15
|
+
`ephemeral_1h_input_tokens` (2×)
|
|
16
|
+
- Terminal table output (default) and Markdown export (`--md`)
|
|
17
|
+
- JSON output (`--json`) for piping into other tools
|
|
18
|
+
- `--plan pro|max` to suppress dollar columns on fixed-price subscriptions
|
|
19
|
+
- Support for current and prior-generation model IDs (Opus 4.6/4.7,
|
|
20
|
+
Sonnet 4.5/4.6, Haiku 4.0/4.5)
|
|
21
|
+
- Family-prefix fallback for unknown future models (e.g. `claude-opus-5-*`
|
|
22
|
+
resolves to Opus pricing)
|
|
23
|
+
- `<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.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for looking. This project is pre-alpha and in active shape — I'm still learning
|
|
4
|
+
what people actually need from a Claude Code ledger.
|
|
5
|
+
|
|
6
|
+
## Quick help
|
|
7
|
+
|
|
8
|
+
The most useful contributions right now:
|
|
9
|
+
|
|
10
|
+
1. **Run it on your own logs and tell me what it got wrong.** Open an issue with
|
|
11
|
+
the shadow cost number `agent-ledger month` reports, and the actual Anthropic
|
|
12
|
+
invoice if you're on pay-as-you-go. I want to calibrate.
|
|
13
|
+
2. **Add a model I missed.** `src/pricing.ts` has the table. Open a PR with the
|
|
14
|
+
model ID and the published rates, plus a test in `test/pricing.test.ts`.
|
|
15
|
+
3. **Report a log format surprise.** If `agent-ledger` skips usage records or
|
|
16
|
+
attributes them to `(main)` when they should be a subagent, paste a redacted
|
|
17
|
+
log snippet into an issue.
|
|
18
|
+
|
|
19
|
+
## Running locally
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun install
|
|
23
|
+
bun test
|
|
24
|
+
bun run typecheck
|
|
25
|
+
bun run bin/agent-ledger.ts today
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Style
|
|
29
|
+
|
|
30
|
+
- TypeScript strict. `noUncheckedIndexedAccess` is on.
|
|
31
|
+
- Short functions. If a function needs a comment to explain what it does, the
|
|
32
|
+
function is probably doing too much.
|
|
33
|
+
- No runtime dependencies except Bun built-ins (we want a small install surface).
|
|
34
|
+
- Tests use `bun:test`. Fixtures live in `test/fixtures/`.
|
|
35
|
+
|
|
36
|
+
## Out of scope (for now)
|
|
37
|
+
|
|
38
|
+
- Real-time dashboards / web UIs
|
|
39
|
+
- Sending data anywhere (the tool is read-only by design)
|
|
40
|
+
- Tracking API keys or authenticating to Anthropic
|
|
41
|
+
|
|
42
|
+
If you want those, that's a different tool — and that's fine, just not this one.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jie Xiang
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# agent-ledger
|
|
2
|
+
|
|
3
|
+
> See exactly where your Claude Code budget goes — per subagent, per task, per day.
|
|
4
|
+
|
|
5
|
+
[](#status)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
Claude Code's invoice at month-end tells you one number. It doesn't tell you:
|
|
13
|
+
|
|
14
|
+
- Which of your subagents burned most of it
|
|
15
|
+
- Which tasks cost $5 and which cost $0.05
|
|
16
|
+
- Whether last week's refactor was 10× more expensive than it should have been
|
|
17
|
+
- Whether your "cheap" Haiku agent is actually quietly expensive
|
|
18
|
+
|
|
19
|
+
If you run **one** Claude Code session, this is fine — you already know.
|
|
20
|
+
If you run **seven** subagents daily (reliability, release, review, iOS factory, docs, ...), you're flying blind.
|
|
21
|
+
|
|
22
|
+
`agent-ledger` reads Claude Code's local session logs and gives you a per-subagent ledger. That's it.
|
|
23
|
+
|
|
24
|
+
## What it is
|
|
25
|
+
|
|
26
|
+
A single-file CLI. No server. No account. No data leaves your machine.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Install
|
|
30
|
+
npm install -g claude-agent-ledger
|
|
31
|
+
# or: bun install -g claude-agent-ledger
|
|
32
|
+
|
|
33
|
+
# Today's spend, grouped by subagent
|
|
34
|
+
agent-ledger today
|
|
35
|
+
|
|
36
|
+
# Last 7 days
|
|
37
|
+
agent-ledger week
|
|
38
|
+
|
|
39
|
+
# Last 30 days + markdown export
|
|
40
|
+
agent-ledger month --md > month-report.md
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Example output
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
agent-ledger week · 2026-04-14 → 2026-04-21
|
|
47
|
+
|
|
48
|
+
subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
|
|
49
|
+
────────────────────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
(main) 27 513K / 15.15M 1h:111.06M 5m: 0 r:4892.68M $11814.53
|
|
51
|
+
general-purpose 145 112K / 876K 1h: 0 5m:19.47M r: 168.34M $548.25
|
|
52
|
+
Swift Developer 13 4K / 574K 1h: 0 5m: 1.61M r: 47.55M $28.93
|
|
53
|
+
Market Researcher 10 107K / 74K 1h: 0 5m: 863K r: 8.61M $7.26
|
|
54
|
+
Explore 13 176K / 77K 1h: 0 5m: 2.60M r: 22.38M $6.47
|
|
55
|
+
zstackws 3 232 / 20K 1h: 0 5m: 829K r: 3.52M $4.47
|
|
56
|
+
claude-code-guide 1 2K / 2K 1h: 0 5m: 56K r: 223K $0.08
|
|
57
|
+
────────────────────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
total 212 915K / 16.77M 1h:111.06M 5m:25.43M r:5143.30M $12409.98
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#shadow-cost-vs-actual-bill).
|
|
62
|
+
|
|
63
|
+
## Shadow cost vs actual bill
|
|
64
|
+
|
|
65
|
+
The `cost` column shows what you **would** pay at Anthropic's pay-as-you-go API rates.
|
|
66
|
+
|
|
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. It's useful for:
|
|
68
|
+
|
|
69
|
+
- Understanding where time-and-token budget goes, regardless of how you pay
|
|
70
|
+
- Spotting runaway agents (a 10× spike is still a 10× spike, capped or not)
|
|
71
|
+
- Deciding when to downshift to Haiku or up to Opus per task
|
|
72
|
+
|
|
73
|
+
If you're on pay-as-you-go, the `cost` column is roughly your bill (±5% depending on tier / geography).
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
Claude Code writes every conversation turn to `~/.claude/projects/<encoded-path>/*.jsonl`.
|
|
78
|
+
Each turn records input/output tokens, cache tokens (with TTL split), and the model used.
|
|
79
|
+
Subagent invocations live under a nested `<session>/subagents/agent-*.jsonl`, with a sidecar
|
|
80
|
+
`.meta.json` that carries the real subagent name (e.g. `Reality Checker`, `ios-factory`).
|
|
81
|
+
|
|
82
|
+
`agent-ledger` walks the tree, resolves subagent types from the sidecar metadata, and
|
|
83
|
+
aggregates by subagent. Pricing is applied locally from `src/pricing.ts`. Nothing is sent
|
|
84
|
+
anywhere — the tool never touches the network after install.
|
|
85
|
+
|
|
86
|
+
### Cache accounting
|
|
87
|
+
|
|
88
|
+
Anthropic's prompt caching has two TTLs in current logs:
|
|
89
|
+
|
|
90
|
+
| Bucket | Multiplier | Notes |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `ephemeral_5m_input_tokens` | 1.25× base input | 5-minute TTL (default) |
|
|
93
|
+
| `ephemeral_1h_input_tokens` | 2.00× base input | 1-hour TTL (long-lived sessions) |
|
|
94
|
+
| `cache_read_input_tokens` | 0.10× base input | Read price (90% discount, TTL-agnostic) |
|
|
95
|
+
|
|
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`.
|
|
97
|
+
|
|
98
|
+
## What it's not
|
|
99
|
+
|
|
100
|
+
- Not a real-time dashboard (post-hoc log reader)
|
|
101
|
+
- Not a tracker for direct Anthropic API calls (those logs live elsewhere)
|
|
102
|
+
- Not a cost-limiter or kill-switch (read-only, by design)
|
|
103
|
+
- Not affiliated with Anthropic
|
|
104
|
+
|
|
105
|
+
## Who this is for
|
|
106
|
+
|
|
107
|
+
- You run ≥ 2 Claude Code subagents and genuinely don't know the per-agent split
|
|
108
|
+
- You're about to have a "wait, I spent *what*?" moment at invoice time
|
|
109
|
+
- You're building AI Agent systems and want observability primitives, not vendor dashboards
|
|
110
|
+
|
|
111
|
+
## Status
|
|
112
|
+
|
|
113
|
+
Pre-alpha. Validated on ~200K turns of real Claude Code logs on the author's machine.
|
|
114
|
+
Pricing model matches Anthropic published rates as of 2026-04-21. Shadow cost values
|
|
115
|
+
may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier bumps,
|
|
116
|
+
server_tool_use which is not yet accounted for).
|
|
117
|
+
|
|
118
|
+
- [x] Log parser (JSONL streaming, recursive subagent discovery)
|
|
119
|
+
- [x] Per-subagent aggregation (from `<session>/subagents/*.meta.json`)
|
|
120
|
+
- [x] Terminal table output
|
|
121
|
+
- [x] Markdown export (`--md`)
|
|
122
|
+
- [x] Cache 1h/5m TTL split pricing
|
|
123
|
+
- [ ] `--plan pro|max` mode (mask shadow cost, show utilization only)
|
|
124
|
+
- [ ] `--json` output mode
|
|
125
|
+
- [ ] Per-model breakdown (`agent-ledger week --by model`)
|
|
126
|
+
- [ ] Cost forecasting (trailing 7-day burn rate)
|
|
127
|
+
- [ ] `server_tool_use` pricing (web search, file fetch)
|
|
128
|
+
- [ ] Homebrew tap
|
|
129
|
+
|
|
130
|
+
First tagged release: **2026-05-05**.
|
|
131
|
+
|
|
132
|
+
## Install from source
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
git clone https://github.com/XJM-free/agent-ledger.git
|
|
136
|
+
cd agent-ledger
|
|
137
|
+
bun install
|
|
138
|
+
bun link
|
|
139
|
+
agent-ledger today
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
bun test
|
|
146
|
+
bun run typecheck
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Author
|
|
150
|
+
|
|
151
|
+
Built by [@XJM-free](https://jiexiang.dev) while running seven production Claude Code
|
|
152
|
+
subagents for infrastructure work. More context: [How I run 7 production AI agents with
|
|
153
|
+
Claude Code](https://jiexiang.dev).
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { aggregate } from '../src/aggregator.ts';
|
|
3
|
+
import { formatMarkdown, formatTable } from '../src/format.ts';
|
|
4
|
+
import { parseAll } from '../src/parser.ts';
|
|
5
|
+
import type { LedgerReport } from '../src/types.ts';
|
|
6
|
+
|
|
7
|
+
type Period = 'today' | 'week' | 'month';
|
|
8
|
+
type Plan = 'payg' | 'pro' | 'max';
|
|
9
|
+
|
|
10
|
+
interface Args {
|
|
11
|
+
period: Period;
|
|
12
|
+
markdown: boolean;
|
|
13
|
+
json: boolean;
|
|
14
|
+
plan: Plan;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function periodRange(period: Period): { from: Date; to: Date } {
|
|
18
|
+
const now = new Date();
|
|
19
|
+
const to = new Date(now);
|
|
20
|
+
const from = new Date(now);
|
|
21
|
+
|
|
22
|
+
if (period === 'today') {
|
|
23
|
+
from.setHours(0, 0, 0, 0);
|
|
24
|
+
} else if (period === 'week') {
|
|
25
|
+
from.setDate(from.getDate() - 7);
|
|
26
|
+
} else {
|
|
27
|
+
from.setDate(from.getDate() - 30);
|
|
28
|
+
}
|
|
29
|
+
return { from, to };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function usage(): never {
|
|
33
|
+
console.error(`Usage: agent-ledger <today|week|month> [flags]
|
|
34
|
+
|
|
35
|
+
Flags:
|
|
36
|
+
--md Emit a Markdown table (good for sharing / commits)
|
|
37
|
+
--json Emit raw JSON (for piping into other tools)
|
|
38
|
+
--plan pro|max Suppress dollar columns. Report token utilization only.
|
|
39
|
+
(Default is pay-as-you-go, which shows shadow cost in $.)
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
agent-ledger today
|
|
43
|
+
agent-ledger week --md > week.md
|
|
44
|
+
agent-ledger month --json | jq '.total.cost.totalCost'
|
|
45
|
+
agent-ledger week --plan max
|
|
46
|
+
`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv: string[]): Args {
|
|
51
|
+
const [period, ...rest] = argv;
|
|
52
|
+
if (!period || !['today', 'week', 'month'].includes(period)) usage();
|
|
53
|
+
|
|
54
|
+
let plan: Plan = 'payg';
|
|
55
|
+
for (let i = 0; i < rest.length; i++) {
|
|
56
|
+
const flag = rest[i];
|
|
57
|
+
if (flag === '--plan') {
|
|
58
|
+
const next = rest[i + 1];
|
|
59
|
+
if (next === 'pro' || next === 'max') {
|
|
60
|
+
plan = next;
|
|
61
|
+
i++;
|
|
62
|
+
} else {
|
|
63
|
+
usage();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
period: period as Period,
|
|
69
|
+
markdown: rest.includes('--md'),
|
|
70
|
+
json: rest.includes('--json'),
|
|
71
|
+
plan,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function planMask(report: LedgerReport, plan: Plan): LedgerReport {
|
|
76
|
+
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
|
+
const zero = () => ({
|
|
80
|
+
inputCost: 0,
|
|
81
|
+
outputCost: 0,
|
|
82
|
+
cacheCreation5mCost: 0,
|
|
83
|
+
cacheCreation1hCost: 0,
|
|
84
|
+
cacheReadCost: 0,
|
|
85
|
+
totalCost: 0,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
period: report.period,
|
|
89
|
+
rows: report.rows.map((r) => ({ ...r, cost: zero() })),
|
|
90
|
+
total: { ...report.total, cost: zero() },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main(): Promise<void> {
|
|
95
|
+
const args = parseArgs(process.argv.slice(2));
|
|
96
|
+
const { from, to } = periodRange(args.period);
|
|
97
|
+
let report = await aggregate(parseAll({ from, to }), from, to);
|
|
98
|
+
report = planMask(report, args.plan);
|
|
99
|
+
|
|
100
|
+
let output: string;
|
|
101
|
+
if (args.json) {
|
|
102
|
+
output = JSON.stringify(report, null, 2);
|
|
103
|
+
} else if (args.markdown) {
|
|
104
|
+
output = formatMarkdown(report, args.period);
|
|
105
|
+
} else {
|
|
106
|
+
output = formatTable(report, args.period);
|
|
107
|
+
}
|
|
108
|
+
console.log(output);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch((err) => {
|
|
112
|
+
console.error('agent-ledger: fatal error');
|
|
113
|
+
console.error(err);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
});
|
package/bun.lock
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "agent-ledger",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/bun": "latest",
|
|
9
|
+
"typescript": "^5.6.0",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
"packages": {
|
|
14
|
+
"@types/bun": ["@types/bun@1.3.12", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.12.tgz", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
|
15
|
+
|
|
16
|
+
"@types/node": ["@types/node@25.6.0", "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
|
17
|
+
|
|
18
|
+
"bun-types": ["bun-types@1.3.12", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.12.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
|
19
|
+
|
|
20
|
+
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
21
|
+
|
|
22
|
+
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
|
23
|
+
}
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-agent-ledger",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "See exactly where your Claude Code budget goes — per subagent, per task.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-ledger": "./bin/agent-ledger.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "bun run bin/agent-ledger.ts",
|
|
11
|
+
"test": "bun test",
|
|
12
|
+
"build": "bun build bin/agent-ledger.ts --target=node --outdir dist",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.1.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude-code",
|
|
20
|
+
"mcp",
|
|
21
|
+
"ai-agents",
|
|
22
|
+
"cost-tracking",
|
|
23
|
+
"observability",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"author": "Jie Xiang <jie.xiang.jm@gmail.com>",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/XJM-free/claude-agent-ledger.git"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "latest",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { costFor } from './pricing.ts';
|
|
2
|
+
import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
|
|
3
|
+
|
|
4
|
+
const MAIN_AGENT_LABEL = '(main)';
|
|
5
|
+
|
|
6
|
+
function emptyRow(subagent: string): AggregatedRow {
|
|
7
|
+
return {
|
|
8
|
+
subagent,
|
|
9
|
+
sessionCount: 0,
|
|
10
|
+
inputTokens: 0,
|
|
11
|
+
outputTokens: 0,
|
|
12
|
+
cacheCreation5mTokens: 0,
|
|
13
|
+
cacheCreation1hTokens: 0,
|
|
14
|
+
cacheReadTokens: 0,
|
|
15
|
+
cost: {
|
|
16
|
+
inputCost: 0,
|
|
17
|
+
outputCost: 0,
|
|
18
|
+
cacheCreation5mCost: 0,
|
|
19
|
+
cacheCreation1hCost: 0,
|
|
20
|
+
cacheReadCost: 0,
|
|
21
|
+
totalCost: 0,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
|
|
27
|
+
const usage = turn.usage;
|
|
28
|
+
if (!usage) return { fiveMin: 0, oneHour: 0 };
|
|
29
|
+
if (usage.cache_creation) {
|
|
30
|
+
return {
|
|
31
|
+
fiveMin: usage.cache_creation.ephemeral_5m_input_tokens ?? 0,
|
|
32
|
+
oneHour: usage.cache_creation.ephemeral_1h_input_tokens ?? 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { fiveMin: usage.cache_creation_input_tokens ?? 0, oneHour: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function aggregate(
|
|
39
|
+
turns: AsyncIterable<SessionTurn>,
|
|
40
|
+
from: Date,
|
|
41
|
+
to: Date,
|
|
42
|
+
): Promise<LedgerReport> {
|
|
43
|
+
const rowsByAgent = new Map<string, AggregatedRow>();
|
|
44
|
+
const sessionsByAgent = new Map<string, Set<string>>();
|
|
45
|
+
|
|
46
|
+
for await (const turn of turns) {
|
|
47
|
+
if (turn.type !== 'assistant' || !turn.usage) continue;
|
|
48
|
+
|
|
49
|
+
const agent = turn.subagentType ?? MAIN_AGENT_LABEL;
|
|
50
|
+
const row = rowsByAgent.get(agent) ?? emptyRow(agent);
|
|
51
|
+
|
|
52
|
+
const cost = costFor(turn.usage, turn.model);
|
|
53
|
+
const { fiveMin, oneHour } = splitCache(turn);
|
|
54
|
+
|
|
55
|
+
row.inputTokens += turn.usage.input_tokens;
|
|
56
|
+
row.outputTokens += turn.usage.output_tokens;
|
|
57
|
+
row.cacheCreation5mTokens += fiveMin;
|
|
58
|
+
row.cacheCreation1hTokens += oneHour;
|
|
59
|
+
row.cacheReadTokens += turn.usage.cache_read_input_tokens ?? 0;
|
|
60
|
+
row.cost.inputCost += cost.inputCost;
|
|
61
|
+
row.cost.outputCost += cost.outputCost;
|
|
62
|
+
row.cost.cacheCreation5mCost += cost.cacheCreation5mCost;
|
|
63
|
+
row.cost.cacheCreation1hCost += cost.cacheCreation1hCost;
|
|
64
|
+
row.cost.cacheReadCost += cost.cacheReadCost;
|
|
65
|
+
row.cost.totalCost += cost.totalCost;
|
|
66
|
+
|
|
67
|
+
const sessions = sessionsByAgent.get(agent) ?? new Set<string>();
|
|
68
|
+
sessions.add(turn.sessionId);
|
|
69
|
+
sessionsByAgent.set(agent, sessions);
|
|
70
|
+
|
|
71
|
+
rowsByAgent.set(agent, row);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [agent, row] of rowsByAgent) {
|
|
75
|
+
row.sessionCount = sessionsByAgent.get(agent)?.size ?? 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rows = [...rowsByAgent.values()].sort((a, b) => b.cost.totalCost - a.cost.totalCost);
|
|
79
|
+
const total = rows.reduce<AggregatedRow>((acc, row) => {
|
|
80
|
+
acc.sessionCount += row.sessionCount;
|
|
81
|
+
acc.inputTokens += row.inputTokens;
|
|
82
|
+
acc.outputTokens += row.outputTokens;
|
|
83
|
+
acc.cacheCreation5mTokens += row.cacheCreation5mTokens;
|
|
84
|
+
acc.cacheCreation1hTokens += row.cacheCreation1hTokens;
|
|
85
|
+
acc.cacheReadTokens += row.cacheReadTokens;
|
|
86
|
+
acc.cost.inputCost += row.cost.inputCost;
|
|
87
|
+
acc.cost.outputCost += row.cost.outputCost;
|
|
88
|
+
acc.cost.cacheCreation5mCost += row.cost.cacheCreation5mCost;
|
|
89
|
+
acc.cost.cacheCreation1hCost += row.cost.cacheCreation1hCost;
|
|
90
|
+
acc.cost.cacheReadCost += row.cost.cacheReadCost;
|
|
91
|
+
acc.cost.totalCost += row.cost.totalCost;
|
|
92
|
+
return acc;
|
|
93
|
+
}, emptyRow('total'));
|
|
94
|
+
|
|
95
|
+
return { period: { from, to }, rows, total };
|
|
96
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AggregatedRow, LedgerReport } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const PAD_SUBAGENT = 22;
|
|
4
|
+
|
|
5
|
+
function fmtTokens(n: number): string {
|
|
6
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
7
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
8
|
+
return String(n);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fmtUsd(n: number): string {
|
|
12
|
+
return `$${n.toFixed(2)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatRow(row: AggregatedRow): string {
|
|
16
|
+
const subagent = row.subagent.padEnd(PAD_SUBAGENT);
|
|
17
|
+
const sessions = String(row.sessionCount).padStart(4);
|
|
18
|
+
const io = `${fmtTokens(row.inputTokens)} / ${fmtTokens(row.outputTokens)}`.padStart(18);
|
|
19
|
+
const cached1h = fmtTokens(row.cacheCreation1hTokens).padStart(7);
|
|
20
|
+
const cached5m = fmtTokens(row.cacheCreation5mTokens).padStart(7);
|
|
21
|
+
const cacheRead = fmtTokens(row.cacheReadTokens).padStart(8);
|
|
22
|
+
const cost = fmtUsd(row.cost.totalCost).padStart(9);
|
|
23
|
+
return ` ${subagent}${sessions} ${io} 1h:${cached1h} 5m:${cached5m} r:${cacheRead} ${cost}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatTable(report: LedgerReport, label: string): string {
|
|
27
|
+
const header = `agent-ledger ${label} · ${report.period.from.toISOString().slice(0, 10)}` +
|
|
28
|
+
` → ${report.period.to.toISOString().slice(0, 10)}`;
|
|
29
|
+
|
|
30
|
+
const cols = ' ' +
|
|
31
|
+
'subagent'.padEnd(PAD_SUBAGENT) +
|
|
32
|
+
'sess'.padStart(4) + ' ' +
|
|
33
|
+
'tokens(in/out)'.padStart(18) + ' ' +
|
|
34
|
+
'cache tokens (1h/5m/read)'.padStart(32) + ' ' +
|
|
35
|
+
'cost'.padStart(9);
|
|
36
|
+
|
|
37
|
+
const sep = ' ' + '─'.repeat(PAD_SUBAGENT + 4 + 2 + 18 + 2 + 32 + 2 + 9);
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
header,
|
|
41
|
+
'',
|
|
42
|
+
cols,
|
|
43
|
+
sep,
|
|
44
|
+
...report.rows.map(formatRow),
|
|
45
|
+
sep,
|
|
46
|
+
formatRow({ ...report.total, subagent: 'total' }),
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatMarkdown(report: LedgerReport, label: string): string {
|
|
51
|
+
const header = '| subagent | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
|
|
52
|
+
const sep = '|---|---:|---:|---:|---:|---:|---:|---:|';
|
|
53
|
+
const rows = report.rows.map(
|
|
54
|
+
(r) =>
|
|
55
|
+
`| ${r.subagent} | ${r.sessionCount} | ${fmtTokens(r.inputTokens)} | ${fmtTokens(
|
|
56
|
+
r.outputTokens,
|
|
57
|
+
)} | ${fmtTokens(r.cacheCreation1hTokens)} | ${fmtTokens(r.cacheCreation5mTokens)} | ${fmtTokens(
|
|
58
|
+
r.cacheReadTokens,
|
|
59
|
+
)} | ${fmtUsd(r.cost.totalCost)} |`,
|
|
60
|
+
);
|
|
61
|
+
const totalRow = `| **total** | ${report.total.sessionCount} | ${fmtTokens(
|
|
62
|
+
report.total.inputTokens,
|
|
63
|
+
)} | ${fmtTokens(report.total.outputTokens)} | ${fmtTokens(
|
|
64
|
+
report.total.cacheCreation1hTokens,
|
|
65
|
+
)} | ${fmtTokens(report.total.cacheCreation5mTokens)} | ${fmtTokens(
|
|
66
|
+
report.total.cacheReadTokens,
|
|
67
|
+
)} | **${fmtUsd(report.total.cost.totalCost)}** |`;
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
`# agent-ledger · ${label}`,
|
|
71
|
+
`_${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}_`,
|
|
72
|
+
'',
|
|
73
|
+
header,
|
|
74
|
+
sep,
|
|
75
|
+
...rows,
|
|
76
|
+
totalRow,
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import type { SessionTurn, TokenUsage } from './types.ts';
|
|
5
|
+
|
|
6
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
7
|
+
|
|
8
|
+
export interface ParseOptions {
|
|
9
|
+
from?: Date;
|
|
10
|
+
to?: Date;
|
|
11
|
+
projectsDir?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface LogFile {
|
|
15
|
+
path: string;
|
|
16
|
+
subagentType: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Walk the projects directory and yield every JSONL log.
|
|
20
|
+
// Real Claude Code layout (as of 2026-04):
|
|
21
|
+
// ~/.claude/projects/<encoded-project>/<session-uuid>.jsonl (main session)
|
|
22
|
+
// ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.jsonl (subagent)
|
|
23
|
+
// ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.meta.json
|
|
24
|
+
//
|
|
25
|
+
// The .meta.json next to each subagent .jsonl carries { "agentType": "...", "description": "..." }.
|
|
26
|
+
// We read it to attribute the subagent by its real name (e.g. "Reality Checker") rather than an opaque hash.
|
|
27
|
+
export async function* findSessionLogs(
|
|
28
|
+
projectsDir: string = CLAUDE_PROJECTS_DIR,
|
|
29
|
+
): AsyncGenerator<LogFile> {
|
|
30
|
+
let projects: string[] = [];
|
|
31
|
+
try {
|
|
32
|
+
projects = await readdir(projectsDir);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const project of projects) {
|
|
37
|
+
yield* walkForJsonl(join(projectsDir, project));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function* walkForJsonl(dir: string): AsyncGenerator<LogFile> {
|
|
42
|
+
let entries: string[] = [];
|
|
43
|
+
try {
|
|
44
|
+
entries = await readdir(dir);
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const name of entries) {
|
|
49
|
+
if (name.startsWith('.') || name === 'memory') continue;
|
|
50
|
+
const full = join(dir, name);
|
|
51
|
+
let isDir = false;
|
|
52
|
+
try {
|
|
53
|
+
isDir = (await stat(full)).isDirectory();
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (isDir) {
|
|
58
|
+
yield* walkForJsonl(full);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (name.endsWith('.jsonl')) {
|
|
62
|
+
yield { path: full, subagentType: await resolveSubagentType(full) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveSubagentType(jsonlPath: string): Promise<string | undefined> {
|
|
68
|
+
// Subagent logs live under `.../subagents/agent-<hash>.jsonl` with a sibling `.meta.json`.
|
|
69
|
+
if (basename(dirname(jsonlPath)) !== 'subagents') return undefined;
|
|
70
|
+
const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
|
|
71
|
+
try {
|
|
72
|
+
const text = await Bun.file(metaPath).text();
|
|
73
|
+
const meta = JSON.parse(text) as { agentType?: string };
|
|
74
|
+
return meta.agentType;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Stream-parse one JSONL file. Skips malformed lines rather than aborting.
|
|
81
|
+
export async function* parseFile(
|
|
82
|
+
path: string,
|
|
83
|
+
subagentType?: string,
|
|
84
|
+
): AsyncGenerator<SessionTurn> {
|
|
85
|
+
const file = Bun.file(path);
|
|
86
|
+
const text = await file.text();
|
|
87
|
+
const sessionId = basename(path).replace('.jsonl', '');
|
|
88
|
+
|
|
89
|
+
for (const line of text.split('\n')) {
|
|
90
|
+
if (!line.trim()) continue;
|
|
91
|
+
let obj: Record<string, unknown>;
|
|
92
|
+
try {
|
|
93
|
+
obj = JSON.parse(line);
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
yield extractTurn(obj, sessionId, subagentType);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractTurn(
|
|
102
|
+
obj: Record<string, unknown>,
|
|
103
|
+
sessionId: string,
|
|
104
|
+
subagentType: string | undefined,
|
|
105
|
+
): SessionTurn {
|
|
106
|
+
const msg = (obj.message ?? {}) as Record<string, unknown>;
|
|
107
|
+
const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
|
|
108
|
+
const model = (msg.model ?? undefined) as string | undefined;
|
|
109
|
+
// If the raw turn carries its own subagentType (our fixture format), prefer it;
|
|
110
|
+
// otherwise fall back to the directory-derived one.
|
|
111
|
+
const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
|
|
115
|
+
timestamp: (obj.timestamp as string) ?? new Date().toISOString(),
|
|
116
|
+
sessionId,
|
|
117
|
+
subagentType: perTurnSubagent ?? subagentType,
|
|
118
|
+
model,
|
|
119
|
+
usage,
|
|
120
|
+
raw: obj,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Convenience: stream every turn across every log under ~/.claude/projects.
|
|
125
|
+
export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
|
|
126
|
+
const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
|
|
127
|
+
for await (const file of findSessionLogs(dir)) {
|
|
128
|
+
for await (const turn of parseFile(file.path, file.subagentType)) {
|
|
129
|
+
if (opts.from && new Date(turn.timestamp) < opts.from) continue;
|
|
130
|
+
if (opts.to && new Date(turn.timestamp) > opts.to) continue;
|
|
131
|
+
yield turn;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CostBreakdown, ModelId, TokenUsage } from './types.ts';
|
|
2
|
+
|
|
3
|
+
// Prices are per 1M tokens, USD. Keep in sync with
|
|
4
|
+
// https://www.anthropic.com/pricing as of 2026-04-21.
|
|
5
|
+
//
|
|
6
|
+
// Prompt caching (Anthropic):
|
|
7
|
+
// cache_creation 5-min TTL: 1.25× base input rate
|
|
8
|
+
// cache_creation 1-hour TTL: 2× base input rate
|
|
9
|
+
// cache_read (any TTL): 0.10× base input rate (90% discount)
|
|
10
|
+
//
|
|
11
|
+
// We include both current and prior-generation model IDs because real logs span
|
|
12
|
+
// version bumps (Claude Code does not auto-rewrite historical logs when you upgrade).
|
|
13
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
14
|
+
// Current generation (2026-04)
|
|
15
|
+
'claude-opus-4-7': { input: 15, output: 75 },
|
|
16
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
17
|
+
'claude-haiku-4-5-20251001': { input: 0.8, output: 4 },
|
|
18
|
+
// Prior generation (still appears in logs written before model upgrades)
|
|
19
|
+
'claude-opus-4-6': { input: 15, output: 75 },
|
|
20
|
+
'claude-opus-4-5': { input: 15, output: 75 },
|
|
21
|
+
'claude-sonnet-4-5': { input: 3, output: 15 },
|
|
22
|
+
'claude-haiku-4-0': { input: 0.8, output: 4 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Internal Claude Code markers that we should treat as free.
|
|
26
|
+
// `<synthetic>` turns are context summaries / compaction events with no real API call.
|
|
27
|
+
const FREE_MODELS = new Set<string>(['<synthetic>']);
|
|
28
|
+
|
|
29
|
+
const CACHE_5M_MULTIPLIER = 1.25;
|
|
30
|
+
const CACHE_1H_MULTIPLIER = 2.0;
|
|
31
|
+
const CACHE_READ_MULTIPLIER = 0.1;
|
|
32
|
+
|
|
33
|
+
function resolveModel(model: ModelId | undefined): { input: number; output: number } {
|
|
34
|
+
if (!model) return { input: 0, output: 0 };
|
|
35
|
+
if (FREE_MODELS.has(model)) return { input: 0, output: 0 };
|
|
36
|
+
const exact = PRICING[model];
|
|
37
|
+
if (exact) return exact;
|
|
38
|
+
// Fuzzy family prefix match as a safety net for unknown subversions.
|
|
39
|
+
// e.g. "claude-opus-4-8-20260301" falls back to Opus pricing until we add it.
|
|
40
|
+
for (const key of Object.keys(PRICING)) {
|
|
41
|
+
if (model.startsWith(key)) return PRICING[key] as { input: number; output: number };
|
|
42
|
+
}
|
|
43
|
+
if (model.startsWith('claude-opus-')) return { input: 15, output: 75 };
|
|
44
|
+
if (model.startsWith('claude-sonnet-')) return { input: 3, output: 15 };
|
|
45
|
+
if (model.startsWith('claude-haiku-')) return { input: 0.8, output: 4 };
|
|
46
|
+
return { input: 0, output: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CacheBreakdown {
|
|
50
|
+
fiveMin: number;
|
|
51
|
+
oneHour: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function splitCacheCreation(usage: TokenUsage): CacheBreakdown {
|
|
55
|
+
if (usage.cache_creation) {
|
|
56
|
+
return {
|
|
57
|
+
fiveMin: usage.cache_creation.ephemeral_5m_input_tokens ?? 0,
|
|
58
|
+
oneHour: usage.cache_creation.ephemeral_1h_input_tokens ?? 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
fiveMin: usage.cache_creation_input_tokens ?? 0,
|
|
63
|
+
oneHour: 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function costFor(usage: TokenUsage, model: ModelId | undefined): CostBreakdown {
|
|
68
|
+
const rate = resolveModel(model);
|
|
69
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
70
|
+
const { fiveMin, oneHour } = splitCacheCreation(usage);
|
|
71
|
+
|
|
72
|
+
const perMillion = (tokens: number, price: number) => (tokens / 1_000_000) * price;
|
|
73
|
+
|
|
74
|
+
const inputCost = perMillion(usage.input_tokens, rate.input);
|
|
75
|
+
const outputCost = perMillion(usage.output_tokens, rate.output);
|
|
76
|
+
const cacheCreation5mCost = perMillion(fiveMin, rate.input * CACHE_5M_MULTIPLIER);
|
|
77
|
+
const cacheCreation1hCost = perMillion(oneHour, rate.input * CACHE_1H_MULTIPLIER);
|
|
78
|
+
const cacheReadCost = perMillion(cacheRead, rate.input * CACHE_READ_MULTIPLIER);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
inputCost,
|
|
82
|
+
outputCost,
|
|
83
|
+
cacheCreation5mCost,
|
|
84
|
+
cacheCreation1hCost,
|
|
85
|
+
cacheReadCost,
|
|
86
|
+
totalCost: inputCost + outputCost + cacheCreation5mCost + cacheCreation1hCost + cacheReadCost,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isKnownModel(model: ModelId | undefined): boolean {
|
|
91
|
+
if (!model) return false;
|
|
92
|
+
if (FREE_MODELS.has(model)) return true;
|
|
93
|
+
if (PRICING[model]) return true;
|
|
94
|
+
return Object.keys(PRICING).some((key) => model.startsWith(key));
|
|
95
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type ModelId =
|
|
2
|
+
| 'claude-opus-4-7'
|
|
3
|
+
| 'claude-sonnet-4-6'
|
|
4
|
+
| 'claude-haiku-4-5-20251001'
|
|
5
|
+
| string;
|
|
6
|
+
|
|
7
|
+
export type TurnType = 'user' | 'assistant' | 'summary' | 'system';
|
|
8
|
+
|
|
9
|
+
export interface CacheCreationDetail {
|
|
10
|
+
ephemeral_5m_input_tokens?: number;
|
|
11
|
+
ephemeral_1h_input_tokens?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TokenUsage {
|
|
15
|
+
input_tokens: number;
|
|
16
|
+
output_tokens: number;
|
|
17
|
+
// Legacy flat counter. Real logs also carry `cache_creation` with TTL split; if present,
|
|
18
|
+
// prefer that for accurate pricing.
|
|
19
|
+
cache_creation_input_tokens?: number;
|
|
20
|
+
cache_read_input_tokens?: number;
|
|
21
|
+
cache_creation?: CacheCreationDetail;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SessionTurn {
|
|
25
|
+
type: TurnType;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
subagentType?: string | undefined;
|
|
29
|
+
model?: ModelId | undefined;
|
|
30
|
+
usage?: TokenUsage | undefined;
|
|
31
|
+
raw: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CostBreakdown {
|
|
35
|
+
inputCost: number;
|
|
36
|
+
outputCost: number;
|
|
37
|
+
cacheCreation5mCost: number;
|
|
38
|
+
cacheCreation1hCost: number;
|
|
39
|
+
cacheReadCost: number;
|
|
40
|
+
totalCost: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AggregatedRow {
|
|
44
|
+
subagent: string;
|
|
45
|
+
sessionCount: number;
|
|
46
|
+
inputTokens: number;
|
|
47
|
+
outputTokens: number;
|
|
48
|
+
cacheCreation5mTokens: number;
|
|
49
|
+
cacheCreation1hTokens: number;
|
|
50
|
+
cacheReadTokens: number;
|
|
51
|
+
cost: CostBreakdown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LedgerReport {
|
|
55
|
+
period: { from: Date; to: Date };
|
|
56
|
+
rows: AggregatedRow[];
|
|
57
|
+
total: AggregatedRow;
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { aggregate } from '../src/aggregator.ts';
|
|
4
|
+
import { parseFile } from '../src/parser.ts';
|
|
5
|
+
|
|
6
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'sample.jsonl');
|
|
7
|
+
|
|
8
|
+
describe('aggregate()', () => {
|
|
9
|
+
test('groups assistant turns by subagent', async () => {
|
|
10
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
11
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
12
|
+
const report = await aggregate(parseFile(FIXTURE), from, to);
|
|
13
|
+
|
|
14
|
+
const agents = report.rows.map((r) => r.subagent);
|
|
15
|
+
expect(agents).toContain('ios-factory');
|
|
16
|
+
expect(agents).toContain('Reality Checker');
|
|
17
|
+
expect(agents).toContain('(main)');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('skips user turns (no usage data)', async () => {
|
|
21
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
22
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
23
|
+
const report = await aggregate(parseFile(FIXTURE), from, to);
|
|
24
|
+
|
|
25
|
+
// 4 assistant turns in fixture (3 subagents, some with >1 turn each).
|
|
26
|
+
const totalSessions = report.total.sessionCount;
|
|
27
|
+
expect(totalSessions).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('rows are sorted by total cost descending', async () => {
|
|
31
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
32
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
33
|
+
const report = await aggregate(parseFile(FIXTURE), from, to);
|
|
34
|
+
|
|
35
|
+
for (let i = 1; i < report.rows.length; i++) {
|
|
36
|
+
const prev = report.rows[i - 1]!.cost.totalCost;
|
|
37
|
+
const curr = report.rows[i]!.cost.totalCost;
|
|
38
|
+
expect(prev).toBeGreaterThanOrEqual(curr);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('total row sums all agent rows', async () => {
|
|
43
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
44
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
45
|
+
const report = await aggregate(parseFile(FIXTURE), from, to);
|
|
46
|
+
|
|
47
|
+
const sumCost = report.rows.reduce((s, r) => s + r.cost.totalCost, 0);
|
|
48
|
+
expect(report.total.cost.totalCost).toBeCloseTo(sumCost, 6);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"type":"assistant","timestamp":"2026-04-21T10:05:00Z","message":{"role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":4200,"output_tokens":1300,"cache_creation_input_tokens":2000,"cache_read_input_tokens":22000}}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-04-21T10:07:00Z","message":{"role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":800,"output_tokens":220,"cache_read_input_tokens":26000}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"agentType":"Reality Checker","description":"Review migration safety"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-04-21T10:00:00Z","message":{"role":"user","content":"hello"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-04-21T10:00:12Z","message":{"role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":1200,"output_tokens":420,"cache_read_input_tokens":8000}}}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-04-21T10:00:00Z","message":{"role":"user","content":"Fix the release script"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-04-21T10:00:12Z","subagentType":"ios-factory","message":{"role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":4200,"output_tokens":1180,"cache_creation_input_tokens":2400,"cache_read_input_tokens":18000}}}
|
|
3
|
+
{"type":"assistant","timestamp":"2026-04-21T10:01:02Z","subagentType":"ios-factory","message":{"role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":2100,"output_tokens":640,"cache_read_input_tokens":22000}}}
|
|
4
|
+
{"type":"user","timestamp":"2026-04-21T11:30:00Z","message":{"role":"user","content":"Review this PR"}}
|
|
5
|
+
{"type":"assistant","timestamp":"2026-04-21T11:30:08Z","subagentType":"Reality Checker","message":{"role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":8800,"output_tokens":1520,"cache_creation_input_tokens":1200,"cache_read_input_tokens":44000}}}
|
|
6
|
+
{"type":"assistant","timestamp":"2026-04-21T15:10:00Z","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","usage":{"input_tokens":1200,"output_tokens":320}}}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { aggregate } from '../src/aggregator.ts';
|
|
4
|
+
import { parseAll } from '../src/parser.ts';
|
|
5
|
+
|
|
6
|
+
const PROJECTS_DIR = join(import.meta.dir, 'fixtures', 'projects');
|
|
7
|
+
|
|
8
|
+
describe('parseAll()', () => {
|
|
9
|
+
test('walks nested subagents/ directories and attributes subagent type', async () => {
|
|
10
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
11
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
12
|
+
|
|
13
|
+
const report = await aggregate(
|
|
14
|
+
parseAll({ from, to, projectsDir: PROJECTS_DIR }),
|
|
15
|
+
from,
|
|
16
|
+
to,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const agents = report.rows.map((r) => r.subagent).sort();
|
|
20
|
+
expect(agents).toContain('(main)');
|
|
21
|
+
expect(agents).toContain('Reality Checker');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('main-session turns are attributed to (main), not to any subagent', async () => {
|
|
25
|
+
const from = new Date('2026-04-21T00:00:00Z');
|
|
26
|
+
const to = new Date('2026-04-21T23:59:59Z');
|
|
27
|
+
|
|
28
|
+
const report = await aggregate(
|
|
29
|
+
parseAll({ from, to, projectsDir: PROJECTS_DIR }),
|
|
30
|
+
from,
|
|
31
|
+
to,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const main = report.rows.find((r) => r.subagent === '(main)');
|
|
35
|
+
const realityChecker = report.rows.find((r) => r.subagent === 'Reality Checker');
|
|
36
|
+
|
|
37
|
+
expect(main).toBeDefined();
|
|
38
|
+
expect(realityChecker).toBeDefined();
|
|
39
|
+
expect(main!.sessionCount).toBe(1);
|
|
40
|
+
expect(realityChecker!.sessionCount).toBe(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('respects date range filter', async () => {
|
|
44
|
+
const from = new Date('2030-01-01T00:00:00Z');
|
|
45
|
+
const to = new Date('2030-01-02T00:00:00Z');
|
|
46
|
+
|
|
47
|
+
const report = await aggregate(
|
|
48
|
+
parseAll({ from, to, projectsDir: PROJECTS_DIR }),
|
|
49
|
+
from,
|
|
50
|
+
to,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(report.rows.length).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { costFor, isKnownModel } from '../src/pricing.ts';
|
|
3
|
+
|
|
4
|
+
describe('costFor()', () => {
|
|
5
|
+
test('Opus output is $75/M', () => {
|
|
6
|
+
const cost = costFor(
|
|
7
|
+
{ input_tokens: 0, output_tokens: 1_000_000 },
|
|
8
|
+
'claude-opus-4-7',
|
|
9
|
+
);
|
|
10
|
+
expect(cost.outputCost).toBeCloseTo(75, 6);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('Opus input is $15/M', () => {
|
|
14
|
+
const cost = costFor(
|
|
15
|
+
{ input_tokens: 1_000_000, output_tokens: 0 },
|
|
16
|
+
'claude-opus-4-7',
|
|
17
|
+
);
|
|
18
|
+
expect(cost.inputCost).toBeCloseTo(15, 6);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('5-minute cache creation is 1.25× base input', () => {
|
|
22
|
+
const cost = costFor(
|
|
23
|
+
{
|
|
24
|
+
input_tokens: 0,
|
|
25
|
+
output_tokens: 0,
|
|
26
|
+
cache_creation: { ephemeral_5m_input_tokens: 1_000_000 },
|
|
27
|
+
},
|
|
28
|
+
'claude-opus-4-7',
|
|
29
|
+
);
|
|
30
|
+
// Opus input $15 × 1.25 = $18.75 per 1M
|
|
31
|
+
expect(cost.cacheCreation5mCost).toBeCloseTo(18.75, 4);
|
|
32
|
+
expect(cost.cacheCreation1hCost).toBeCloseTo(0, 6);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('1-hour cache creation is 2× base input (the TTL trap)', () => {
|
|
36
|
+
const cost = costFor(
|
|
37
|
+
{
|
|
38
|
+
input_tokens: 0,
|
|
39
|
+
output_tokens: 0,
|
|
40
|
+
cache_creation: { ephemeral_1h_input_tokens: 1_000_000 },
|
|
41
|
+
},
|
|
42
|
+
'claude-opus-4-7',
|
|
43
|
+
);
|
|
44
|
+
// Opus input $15 × 2 = $30 per 1M
|
|
45
|
+
expect(cost.cacheCreation1hCost).toBeCloseTo(30, 4);
|
|
46
|
+
expect(cost.cacheCreation5mCost).toBeCloseTo(0, 6);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('cache read is 10% of base input', () => {
|
|
50
|
+
const cost = costFor(
|
|
51
|
+
{
|
|
52
|
+
input_tokens: 0,
|
|
53
|
+
output_tokens: 0,
|
|
54
|
+
cache_read_input_tokens: 1_000_000,
|
|
55
|
+
},
|
|
56
|
+
'claude-opus-4-7',
|
|
57
|
+
);
|
|
58
|
+
// Opus input $15 × 0.1 = $1.50 per 1M
|
|
59
|
+
expect(cost.cacheReadCost).toBeCloseTo(1.5, 4);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('legacy flat cache_creation_input_tokens falls back to 5m TTL pricing', () => {
|
|
63
|
+
const cost = costFor(
|
|
64
|
+
{
|
|
65
|
+
input_tokens: 0,
|
|
66
|
+
output_tokens: 0,
|
|
67
|
+
cache_creation_input_tokens: 1_000_000,
|
|
68
|
+
},
|
|
69
|
+
'claude-opus-4-7',
|
|
70
|
+
);
|
|
71
|
+
expect(cost.cacheCreation5mCost).toBeCloseTo(18.75, 4);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('prior-generation model IDs (opus-4-6) resolve to current pricing', () => {
|
|
75
|
+
const cost = costFor(
|
|
76
|
+
{ input_tokens: 1_000_000, output_tokens: 0 },
|
|
77
|
+
'claude-opus-4-6',
|
|
78
|
+
);
|
|
79
|
+
expect(cost.inputCost).toBeCloseTo(15, 6);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('unknown future opus variant falls back to opus family pricing', () => {
|
|
83
|
+
const cost = costFor(
|
|
84
|
+
{ input_tokens: 1_000_000, output_tokens: 0 },
|
|
85
|
+
'claude-opus-9-9',
|
|
86
|
+
);
|
|
87
|
+
expect(cost.inputCost).toBeCloseTo(15, 6);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('<synthetic> model is free', () => {
|
|
91
|
+
const cost = costFor(
|
|
92
|
+
{
|
|
93
|
+
input_tokens: 1_000_000,
|
|
94
|
+
output_tokens: 1_000_000,
|
|
95
|
+
cache_read_input_tokens: 1_000_000,
|
|
96
|
+
},
|
|
97
|
+
'<synthetic>',
|
|
98
|
+
);
|
|
99
|
+
expect(cost.totalCost).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('isKnownModel recognizes current, prior, and family matches', () => {
|
|
103
|
+
expect(isKnownModel('claude-opus-4-7')).toBe(true);
|
|
104
|
+
expect(isKnownModel('claude-opus-4-6')).toBe(true);
|
|
105
|
+
expect(isKnownModel('claude-sonnet-4-6')).toBe(true);
|
|
106
|
+
expect(isKnownModel('<synthetic>')).toBe(true);
|
|
107
|
+
expect(isKnownModel('something-entirely-unknown')).toBe(false);
|
|
108
|
+
expect(isKnownModel(undefined)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["bun-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noImplicitOverride": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"verbatimModuleSyntax": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*", "bin/**/*", "test/**/*"]
|
|
20
|
+
}
|