blackbox-bill 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blackbox_bill-0.2.0/LICENSE +21 -0
- blackbox_bill-0.2.0/PKG-INFO +156 -0
- blackbox_bill-0.2.0/README.md +145 -0
- blackbox_bill-0.2.0/pyproject.toml +19 -0
- blackbox_bill-0.2.0/setup.cfg +4 -0
- blackbox_bill-0.2.0/src/blackbox_bill/__init__.py +7 -0
- blackbox_bill-0.2.0/src/blackbox_bill/__main__.py +4 -0
- blackbox_bill-0.2.0/src/blackbox_bill/attribution.py +269 -0
- blackbox_bill-0.2.0/src/blackbox_bill/cli.py +333 -0
- blackbox_bill-0.2.0/src/blackbox_bill/cursor_csv.py +229 -0
- blackbox_bill-0.2.0/src/blackbox_bill/explain.py +327 -0
- blackbox_bill-0.2.0/src/blackbox_bill/join.py +61 -0
- blackbox_bill-0.2.0/src/blackbox_bill/logs.py +232 -0
- blackbox_bill-0.2.0/src/blackbox_bill/tokenizer.py +87 -0
- blackbox_bill-0.2.0/src/blackbox_bill/whatif.py +140 -0
- blackbox_bill-0.2.0/src/blackbox_bill.egg-info/PKG-INFO +156 -0
- blackbox_bill-0.2.0/src/blackbox_bill.egg-info/SOURCES.txt +20 -0
- blackbox_bill-0.2.0/src/blackbox_bill.egg-info/dependency_links.txt +1 -0
- blackbox_bill-0.2.0/src/blackbox_bill.egg-info/entry_points.txt +2 -0
- blackbox_bill-0.2.0/src/blackbox_bill.egg-info/top_level.txt +1 -0
- blackbox_bill-0.2.0/tests/test_blackbox_bill.py +397 -0
- blackbox_bill-0.2.0/tests/test_v2.py +330 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blackbox-bill
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Explain where your AI-coding-tool money went: billing black-box explainer with tokenizer-aware per-project and subagent cost attribution
|
|
5
|
+
Author: hao li
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# blackbox-bill
|
|
13
|
+
|
|
14
|
+
**Explain where your AI-coding-tool money went.** A stdlib-only Python CLI that reads your Cursor billing export and your local session logs, then tells you — in plain English — why your bill looks the way it does. v2 adds tokenizer-aware cost attribution: per-project breakdowns and subagent token share, straight from your `~/.claude/projects` transcripts.
|
|
15
|
+
|
|
16
|
+
No network. No API keys. Deterministic: every explanation cites the exact rows it was computed from.
|
|
17
|
+
|
|
18
|
+
## The problem
|
|
19
|
+
|
|
20
|
+
Cursor's billing pools confuse everyone:
|
|
21
|
+
|
|
22
|
+
- u/josh3com on r/ClaudeCode: *"CURSOR TEAM PLEASE HELP - Cursor support keeps telling me my 100% full usage pool is from grok bot two months in a row"* — Grok Bot spend kept landing in "Other Models" with no explanation in the app.
|
|
23
|
+
- u/FerretValuable9718: *"Why is the 'Other Models' usage being used so much with so little time?"* — screenshot showed **Other Models 100% used while Cursor Models sat at 16%**.
|
|
24
|
+
- r/AI_Agents: *"My coding agent hit a cold-start 503, found a Gemini key in my repo, and burned $40 while I slept"* — spend routed to a provider the user never intended.
|
|
25
|
+
- dev.to: *"$402 on one ticket"* in headless Claude Code — *"nothing in the app said so"*, so the author built manual bookkeeping.
|
|
26
|
+
- r/ClaudeCode, October 2026: Fable 5.1 requests silently served by 5.5 — the model on the invoice doesn't match the model you asked for.
|
|
27
|
+
|
|
28
|
+
Forums are full of people doing billing archaeology by hand. blackbox-bill automates the archaeology.
|
|
29
|
+
|
|
30
|
+
## What it does
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
blackbox-bill --csv cursor-export.csv
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Given a Cursor billing export CSV (columns vary — the parser is deliberately liberal), it prints:
|
|
37
|
+
|
|
38
|
+
1. **Pool-normalized summary** — first-party Cursor pool vs "Other Models" pool vs API-key spend, per model, per day. Free-form pool labels are normalized to the three canonical pools; unrecognized labels are reported as `unknown`, never silently dropped.
|
|
39
|
+
2. **Tokenizer-aware token figures** — every token total is shown in both tokenizer equivalents (`--tokenizer legacy|claude-4.7`, `--tokenizer-factor` to override the default 1.3 ratio). Claude 4.7+'s new tokenizer emits ~30% more tokens for the same text; without this, a workload looks cheaper than it bills.
|
|
40
|
+
3. **Plain-English explanations** from rule-based anomaly detection:
|
|
41
|
+
- *"Why your 'Other Models' pool drained first"* — auto-model routing sent X% of requests to non-Cursor models.
|
|
42
|
+
- *"The grok-4 spend landed in unexpected pool(s)"* — one model billed across multiple pools.
|
|
43
|
+
- *"'fable-5.1' requests were routed to 'fable-5.5'"* — silent version swaps, with the N local sessions listed.
|
|
44
|
+
- *"Unattended burn: $X spent while no interactive session was open"* — the "$40 while I slept" case.
|
|
45
|
+
- *"Sudden pool switch on <date>"* — same usage pattern, different bucket mid-cycle.
|
|
46
|
+
3. **Local-log cross-check** — joins bill rows against your `~/.claude/projects/**/*.jsonl` and `~/.codex/sessions/` by time window, reporting matched vs orphaned spend.
|
|
47
|
+
4. **Plan-switch what-if simulator**:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
blackbox-bill --csv cursor-export.csv --whatif cursor:ultra
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
re-prices observed token usage under a different plan's rates and prints the delta vs the actual bill. Bundled rate cards are **illustrative placeholders, not real Cursor pricing** — pass `--rates-file rates.json` with your own numbers (see `src/blackbox_bill/whatif.py` for the format).
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
blackbox-bill --csv export.csv --markdown report.md --since 2026-09-01
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Exit codes: `0` ok, `2` usage error (unreadable CSV, unknown plan). Missing local log directories are warnings, not errors — the bill alone is still explainable.
|
|
60
|
+
|
|
61
|
+
## Cost attribution from local logs (no bill needed)
|
|
62
|
+
|
|
63
|
+
`blackbox-bill report` works without a CSV: it reads your Claude Code transcripts and answers the questions forums keep asking.
|
|
64
|
+
|
|
65
|
+
**Per-project attribution** — which project burned the quota (u/rosehsu47's complaint: `/usage` can't tell you which project spent it, so people hand-roll quota-audit scripts over the same jsonl files):
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
blackbox-bill report --by-project
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Aggregates tokens, estimated cost, and per-session detail per `~/.claude/projects/*` directory, sorted by estimated spend. Dollar figures are estimates from an illustrative built-in rate card — pass `--rates-file` with your own per-1M-token rates for real numbers.
|
|
72
|
+
|
|
73
|
+
**Subagent attribution** — how much of the burn was subagents (u/aidiveyt measured 2,631 subagent runs eating **48.1%** of tokens):
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
blackbox-bill report --by-agent --top 10
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Subagent token share is measured on sidechain transcript messages; the Top-N ranking attributes sidechain tokens to the invoked `subagent_type`s (Task tool calls), split proportionally.
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
blackbox-bill report --by-project --by-agent --tokenizer claude-4.7 \
|
|
83
|
+
--rates-file my-rates.json --markdown attribution.md
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Non-goals (what this is NOT)
|
|
87
|
+
|
|
88
|
+
- **[agentcost-cli](https://dev.to/justinli2026/how-i-built-a-unified-cost-tracker-for-ai-coding-agents-claude-code-cursor-codex-1nid)** does multi-tool budget observability dashboards with WARN/OVERRUN budget lines. blackbox-bill has no dashboards, no budget lines, no MCP server — it explains one bill, once.
|
|
89
|
+
- **resume-budget-guard** (sibling project) enforces cumulative spend caps across Claude Code `--resume` runs. blackbox-bill never blocks or enforces anything; it is read-only analysis.
|
|
90
|
+
- **ccusage** covers Claude Code usage attribution (daily/monthly/session/blocks, per-model breakdown). blackbox-bill deliberately does not re-do Claude Code attribution — its job is the *cross-tool routing black box* (Cursor pools, unexpected providers, silent model swaps).
|
|
91
|
+
|
|
92
|
+
One sentence: the others tell you *how much* you spent or *stop* you from spending; blackbox-bill tells you *why the bill looks wrong*.
|
|
93
|
+
|
|
94
|
+
## How it differs from statusline dashboards
|
|
95
|
+
|
|
96
|
+
Tools like [uppinote20/claude-dashboard](https://github.com/uppinote20/claude-dashboard) are **observability statuslines**: they live inside your IDE, rendering live context bars, rate-limit countdowns, and burn-rate widgets while you work. That's a telescope pointed at the present.
|
|
97
|
+
|
|
98
|
+
blackbox-bill is a **billing-attribution ledger**: it runs after the fact, off local files only, and reconstructs *where the money went* — which pool, which project, which subagents, under which tokenizer's counting. No plugin install, no Node runtime, no live session needed — just the bill CSV and the transcripts you already have. Use the statusline to watch the burn; use blackbox-bill to audit it.
|
|
99
|
+
|
|
100
|
+
## Honest limitations
|
|
101
|
+
|
|
102
|
+
- **Cursor changes its export format.** The parser fuzzy-matches headers and fails soft on missing columns, but a radically new format may need new aliases — contributions welcome.
|
|
103
|
+
- **Routing rules are inferred, not official.** Explanations are heuristics built from observed community patterns ("Other Models drains first", "Grok Bot mis-bucketing"). They are not Cursor/OpenAI/Anthropic documentation, and every explanation says so.
|
|
104
|
+
- **Times are local-timezone best-effort.** Bill timestamps and local log timestamps are compared as wall-clock times on the same machine. If your export is in UTC and your logs are local, widen `--window-hours` or convert first.
|
|
105
|
+
- **The join is evidence, not proof.** A bill row with no matching local session might be a background agent, a headless run, another machine, or a leaked key — the tool reports the gap; you investigate.
|
|
106
|
+
- **Token-based re-pricing is approximate.** The what-if simulator re-prices tokens at flat rates; real plans have request-based pricing, overage rules, and weekly pools (e.g. SuperGrok bundles) that flat rates cannot model. Use `--rates-file` for your own numbers.
|
|
107
|
+
|
|
108
|
+
## Install
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install blackbox-bill
|
|
112
|
+
# or from source:
|
|
113
|
+
pip install .
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Zero dependencies — Python 3.9+ stdlib only.
|
|
117
|
+
|
|
118
|
+
## Example output
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
blackbox-bill v0.2.0 — where the money went
|
|
122
|
+
============================================================
|
|
123
|
+
Bill rows: 5 requests: 68 billed total: $27.00
|
|
124
|
+
|
|
125
|
+
## Tokenizer-aware token figures
|
|
126
|
+
Measured under: legacy tokenizer (Claude <=4.5).
|
|
127
|
+
Input tokens: 300.0K (legacy) ~= 390.0K (claude-4.7, x1.3)
|
|
128
|
+
Output tokens: 60.0K (legacy) ~= 78.0K (claude-4.7, x1.3)
|
|
129
|
+
Total tokens: 360.0K (legacy) ~= 468.0K (claude-4.7, x1.3)
|
|
130
|
+
(claude-4.7+ figures are approximations, not re-tokenized counts)
|
|
131
|
+
|
|
132
|
+
## Pool-normalized summary
|
|
133
|
+
cursor-models 2 rows 8 reqs $ 3.00
|
|
134
|
+
claude-opus-5.5 8 reqs $ 3.00
|
|
135
|
+
other-models 3 rows 60 reqs $ 24.00
|
|
136
|
+
grok-4 40 reqs $ 16.00
|
|
137
|
+
gemini-4 20 reqs $ 8.00
|
|
138
|
+
|
|
139
|
+
## Explanations
|
|
140
|
+
|
|
141
|
+
[pool-drain] Why your 'Other Models' pool drained first
|
|
142
|
+
Auto-model routing sent most of your billable work to non-Cursor models...
|
|
143
|
+
Evidence: 3 row(s), 60 request(s), $24.00.
|
|
144
|
+
...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
python3 tests/test_blackbox_bill.py # 41 checks, <1s
|
|
151
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -t . # 28 v2 checks
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# blackbox-bill
|
|
2
|
+
|
|
3
|
+
**Explain where your AI-coding-tool money went.** A stdlib-only Python CLI that reads your Cursor billing export and your local session logs, then tells you — in plain English — why your bill looks the way it does. v2 adds tokenizer-aware cost attribution: per-project breakdowns and subagent token share, straight from your `~/.claude/projects` transcripts.
|
|
4
|
+
|
|
5
|
+
No network. No API keys. Deterministic: every explanation cites the exact rows it was computed from.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
Cursor's billing pools confuse everyone:
|
|
10
|
+
|
|
11
|
+
- u/josh3com on r/ClaudeCode: *"CURSOR TEAM PLEASE HELP - Cursor support keeps telling me my 100% full usage pool is from grok bot two months in a row"* — Grok Bot spend kept landing in "Other Models" with no explanation in the app.
|
|
12
|
+
- u/FerretValuable9718: *"Why is the 'Other Models' usage being used so much with so little time?"* — screenshot showed **Other Models 100% used while Cursor Models sat at 16%**.
|
|
13
|
+
- r/AI_Agents: *"My coding agent hit a cold-start 503, found a Gemini key in my repo, and burned $40 while I slept"* — spend routed to a provider the user never intended.
|
|
14
|
+
- dev.to: *"$402 on one ticket"* in headless Claude Code — *"nothing in the app said so"*, so the author built manual bookkeeping.
|
|
15
|
+
- r/ClaudeCode, October 2026: Fable 5.1 requests silently served by 5.5 — the model on the invoice doesn't match the model you asked for.
|
|
16
|
+
|
|
17
|
+
Forums are full of people doing billing archaeology by hand. blackbox-bill automates the archaeology.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
blackbox-bill --csv cursor-export.csv
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Given a Cursor billing export CSV (columns vary — the parser is deliberately liberal), it prints:
|
|
26
|
+
|
|
27
|
+
1. **Pool-normalized summary** — first-party Cursor pool vs "Other Models" pool vs API-key spend, per model, per day. Free-form pool labels are normalized to the three canonical pools; unrecognized labels are reported as `unknown`, never silently dropped.
|
|
28
|
+
2. **Tokenizer-aware token figures** — every token total is shown in both tokenizer equivalents (`--tokenizer legacy|claude-4.7`, `--tokenizer-factor` to override the default 1.3 ratio). Claude 4.7+'s new tokenizer emits ~30% more tokens for the same text; without this, a workload looks cheaper than it bills.
|
|
29
|
+
3. **Plain-English explanations** from rule-based anomaly detection:
|
|
30
|
+
- *"Why your 'Other Models' pool drained first"* — auto-model routing sent X% of requests to non-Cursor models.
|
|
31
|
+
- *"The grok-4 spend landed in unexpected pool(s)"* — one model billed across multiple pools.
|
|
32
|
+
- *"'fable-5.1' requests were routed to 'fable-5.5'"* — silent version swaps, with the N local sessions listed.
|
|
33
|
+
- *"Unattended burn: $X spent while no interactive session was open"* — the "$40 while I slept" case.
|
|
34
|
+
- *"Sudden pool switch on <date>"* — same usage pattern, different bucket mid-cycle.
|
|
35
|
+
3. **Local-log cross-check** — joins bill rows against your `~/.claude/projects/**/*.jsonl` and `~/.codex/sessions/` by time window, reporting matched vs orphaned spend.
|
|
36
|
+
4. **Plan-switch what-if simulator**:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
blackbox-bill --csv cursor-export.csv --whatif cursor:ultra
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
re-prices observed token usage under a different plan's rates and prints the delta vs the actual bill. Bundled rate cards are **illustrative placeholders, not real Cursor pricing** — pass `--rates-file rates.json` with your own numbers (see `src/blackbox_bill/whatif.py` for the format).
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
blackbox-bill --csv export.csv --markdown report.md --since 2026-09-01
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Exit codes: `0` ok, `2` usage error (unreadable CSV, unknown plan). Missing local log directories are warnings, not errors — the bill alone is still explainable.
|
|
49
|
+
|
|
50
|
+
## Cost attribution from local logs (no bill needed)
|
|
51
|
+
|
|
52
|
+
`blackbox-bill report` works without a CSV: it reads your Claude Code transcripts and answers the questions forums keep asking.
|
|
53
|
+
|
|
54
|
+
**Per-project attribution** — which project burned the quota (u/rosehsu47's complaint: `/usage` can't tell you which project spent it, so people hand-roll quota-audit scripts over the same jsonl files):
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
blackbox-bill report --by-project
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Aggregates tokens, estimated cost, and per-session detail per `~/.claude/projects/*` directory, sorted by estimated spend. Dollar figures are estimates from an illustrative built-in rate card — pass `--rates-file` with your own per-1M-token rates for real numbers.
|
|
61
|
+
|
|
62
|
+
**Subagent attribution** — how much of the burn was subagents (u/aidiveyt measured 2,631 subagent runs eating **48.1%** of tokens):
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
blackbox-bill report --by-agent --top 10
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Subagent token share is measured on sidechain transcript messages; the Top-N ranking attributes sidechain tokens to the invoked `subagent_type`s (Task tool calls), split proportionally.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
blackbox-bill report --by-project --by-agent --tokenizer claude-4.7 \
|
|
72
|
+
--rates-file my-rates.json --markdown attribution.md
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Non-goals (what this is NOT)
|
|
76
|
+
|
|
77
|
+
- **[agentcost-cli](https://dev.to/justinli2026/how-i-built-a-unified-cost-tracker-for-ai-coding-agents-claude-code-cursor-codex-1nid)** does multi-tool budget observability dashboards with WARN/OVERRUN budget lines. blackbox-bill has no dashboards, no budget lines, no MCP server — it explains one bill, once.
|
|
78
|
+
- **resume-budget-guard** (sibling project) enforces cumulative spend caps across Claude Code `--resume` runs. blackbox-bill never blocks or enforces anything; it is read-only analysis.
|
|
79
|
+
- **ccusage** covers Claude Code usage attribution (daily/monthly/session/blocks, per-model breakdown). blackbox-bill deliberately does not re-do Claude Code attribution — its job is the *cross-tool routing black box* (Cursor pools, unexpected providers, silent model swaps).
|
|
80
|
+
|
|
81
|
+
One sentence: the others tell you *how much* you spent or *stop* you from spending; blackbox-bill tells you *why the bill looks wrong*.
|
|
82
|
+
|
|
83
|
+
## How it differs from statusline dashboards
|
|
84
|
+
|
|
85
|
+
Tools like [uppinote20/claude-dashboard](https://github.com/uppinote20/claude-dashboard) are **observability statuslines**: they live inside your IDE, rendering live context bars, rate-limit countdowns, and burn-rate widgets while you work. That's a telescope pointed at the present.
|
|
86
|
+
|
|
87
|
+
blackbox-bill is a **billing-attribution ledger**: it runs after the fact, off local files only, and reconstructs *where the money went* — which pool, which project, which subagents, under which tokenizer's counting. No plugin install, no Node runtime, no live session needed — just the bill CSV and the transcripts you already have. Use the statusline to watch the burn; use blackbox-bill to audit it.
|
|
88
|
+
|
|
89
|
+
## Honest limitations
|
|
90
|
+
|
|
91
|
+
- **Cursor changes its export format.** The parser fuzzy-matches headers and fails soft on missing columns, but a radically new format may need new aliases — contributions welcome.
|
|
92
|
+
- **Routing rules are inferred, not official.** Explanations are heuristics built from observed community patterns ("Other Models drains first", "Grok Bot mis-bucketing"). They are not Cursor/OpenAI/Anthropic documentation, and every explanation says so.
|
|
93
|
+
- **Times are local-timezone best-effort.** Bill timestamps and local log timestamps are compared as wall-clock times on the same machine. If your export is in UTC and your logs are local, widen `--window-hours` or convert first.
|
|
94
|
+
- **The join is evidence, not proof.** A bill row with no matching local session might be a background agent, a headless run, another machine, or a leaked key — the tool reports the gap; you investigate.
|
|
95
|
+
- **Token-based re-pricing is approximate.** The what-if simulator re-prices tokens at flat rates; real plans have request-based pricing, overage rules, and weekly pools (e.g. SuperGrok bundles) that flat rates cannot model. Use `--rates-file` for your own numbers.
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install blackbox-bill
|
|
101
|
+
# or from source:
|
|
102
|
+
pip install .
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Zero dependencies — Python 3.9+ stdlib only.
|
|
106
|
+
|
|
107
|
+
## Example output
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
blackbox-bill v0.2.0 — where the money went
|
|
111
|
+
============================================================
|
|
112
|
+
Bill rows: 5 requests: 68 billed total: $27.00
|
|
113
|
+
|
|
114
|
+
## Tokenizer-aware token figures
|
|
115
|
+
Measured under: legacy tokenizer (Claude <=4.5).
|
|
116
|
+
Input tokens: 300.0K (legacy) ~= 390.0K (claude-4.7, x1.3)
|
|
117
|
+
Output tokens: 60.0K (legacy) ~= 78.0K (claude-4.7, x1.3)
|
|
118
|
+
Total tokens: 360.0K (legacy) ~= 468.0K (claude-4.7, x1.3)
|
|
119
|
+
(claude-4.7+ figures are approximations, not re-tokenized counts)
|
|
120
|
+
|
|
121
|
+
## Pool-normalized summary
|
|
122
|
+
cursor-models 2 rows 8 reqs $ 3.00
|
|
123
|
+
claude-opus-5.5 8 reqs $ 3.00
|
|
124
|
+
other-models 3 rows 60 reqs $ 24.00
|
|
125
|
+
grok-4 40 reqs $ 16.00
|
|
126
|
+
gemini-4 20 reqs $ 8.00
|
|
127
|
+
|
|
128
|
+
## Explanations
|
|
129
|
+
|
|
130
|
+
[pool-drain] Why your 'Other Models' pool drained first
|
|
131
|
+
Auto-model routing sent most of your billable work to non-Cursor models...
|
|
132
|
+
Evidence: 3 row(s), 60 request(s), $24.00.
|
|
133
|
+
...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
python3 tests/test_blackbox_bill.py # 41 checks, <1s
|
|
140
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -t . # 28 v2 checks
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "blackbox-bill"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Explain where your AI-coding-tool money went: billing black-box explainer with tokenizer-aware per-project and subagent cost attribution"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "hao li" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
blackbox-bill = "blackbox_bill.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""blackbox-bill: explain where your AI-coding-tool money went.
|
|
2
|
+
|
|
3
|
+
Deterministic, stdlib-only, no network, no API keys. Reads files you
|
|
4
|
+
already have (Cursor billing CSV exports, local Claude Code / Codex
|
|
5
|
+
session logs) and explains routing anomalies in plain English.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Cost attribution: per-project and subagent breakdowns from local logs.
|
|
2
|
+
|
|
3
|
+
A billing CSV tells you *which pool* the money left from. These reports
|
|
4
|
+
answer the questions users actually ask in forums:
|
|
5
|
+
|
|
6
|
+
- "which project burned my quota?" (per-project attribution over
|
|
7
|
+
``~/.claude/projects/*/*.jsonl``)
|
|
8
|
+
- "how much of my spend was subagents?" (subagent share + Top-N ranking)
|
|
9
|
+
|
|
10
|
+
Token counts come straight from the transcripts. Dollar figures are
|
|
11
|
+
*estimates*: they re-price observed tokens with a rate card. The bundled
|
|
12
|
+
card is illustrative; pass ``--rates-file`` with your own numbers.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
from .tokenizer import dual_line, fmt_tokens
|
|
18
|
+
|
|
19
|
+
# Illustrative rates, USD per 1M tokens. NOT real vendor pricing — the tool
|
|
20
|
+
# has no network and vendor pricing changes. See --rates-file.
|
|
21
|
+
ILLUSTRATIVE_RATES = {
|
|
22
|
+
"label": "illustrative built-in rates (NOT real pricing — use --rates-file)",
|
|
23
|
+
"default": {"input": 3.0, "output": 15.0},
|
|
24
|
+
"patterns": [
|
|
25
|
+
(["opus"], {"input": 15.0, "output": 75.0}),
|
|
26
|
+
(["sonnet"], {"input": 3.0, "output": 15.0}),
|
|
27
|
+
(["haiku"], {"input": 1.0, "output": 5.0}),
|
|
28
|
+
(["gpt-5", "gpt-4"], {"input": 5.0, "output": 20.0}),
|
|
29
|
+
(["gemini"], {"input": 2.0, "output": 12.0}),
|
|
30
|
+
(["grok"], {"input": 3.0, "output": 15.0}),
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_rate_card(path=None):
|
|
36
|
+
"""Load a rate card: {'label', 'default': {input, output}, 'patterns': [...]}.
|
|
37
|
+
|
|
38
|
+
JSON format for --rates-file::
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
"label": "my actual rates",
|
|
42
|
+
"default": {"input": 3.0, "output": 15.0},
|
|
43
|
+
"patterns": [
|
|
44
|
+
{"match": ["opus"], "input": 15.0, "output": 75.0}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Rates are USD per 1M tokens. Without a file, the illustrative
|
|
49
|
+
built-in card is returned (clearly labeled as such).
|
|
50
|
+
"""
|
|
51
|
+
if path is None:
|
|
52
|
+
return ILLUSTRATIVE_RATES
|
|
53
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
54
|
+
data = json.load(fh)
|
|
55
|
+
card = {
|
|
56
|
+
"label": data.get("label", path),
|
|
57
|
+
"default": {
|
|
58
|
+
"input": float(data.get("default", {}).get("input", 3.0)),
|
|
59
|
+
"output": float(data.get("default", {}).get("output", 15.0)),
|
|
60
|
+
},
|
|
61
|
+
"patterns": [],
|
|
62
|
+
}
|
|
63
|
+
for p in data.get("patterns", []) or []:
|
|
64
|
+
match = p.get("match") or []
|
|
65
|
+
if isinstance(match, str):
|
|
66
|
+
match = [match]
|
|
67
|
+
card["patterns"].append((
|
|
68
|
+
[str(m).lower() for m in match],
|
|
69
|
+
{"input": float(p.get("input", 3.0)),
|
|
70
|
+
"output": float(p.get("output", 15.0))},
|
|
71
|
+
))
|
|
72
|
+
return card
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rate_for(model, card=None):
|
|
76
|
+
"""Pick (input_per_1M, output_per_1M) for a model name."""
|
|
77
|
+
card = card or ILLUSTRATIVE_RATES
|
|
78
|
+
name = (model or "").lower()
|
|
79
|
+
for needles, rate in card.get("patterns", []):
|
|
80
|
+
if any(n in name for n in needles):
|
|
81
|
+
return rate["input"], rate["output"]
|
|
82
|
+
d = card.get("default", {"input": 3.0, "output": 15.0})
|
|
83
|
+
return d["input"], d["output"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def estimate_cost(input_tokens, output_tokens, model, card=None):
|
|
87
|
+
"""Estimated USD cost of token usage under a rate card."""
|
|
88
|
+
rin, rout = rate_for(model, card)
|
|
89
|
+
return input_tokens / 1e6 * rin + output_tokens / 1e6 * rout
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _in_window(session, since, until):
|
|
93
|
+
ts = session.get("first_ts") or session.get("last_ts")
|
|
94
|
+
if ts is None:
|
|
95
|
+
return True # undated sessions are kept, same as undated bill rows
|
|
96
|
+
d = ts.date()
|
|
97
|
+
if since and d < since:
|
|
98
|
+
return False
|
|
99
|
+
if until and d > until:
|
|
100
|
+
return False
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _session_cost(session, card):
|
|
105
|
+
model = session.get("model") or ""
|
|
106
|
+
return estimate_cost(session.get("input_tokens", 0),
|
|
107
|
+
session.get("output_tokens", 0), model, card)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def by_project(sessions, since=None, until=None, card=None):
|
|
111
|
+
"""Aggregate sessions by project directory.
|
|
112
|
+
|
|
113
|
+
Returns a list of project dicts sorted by estimated cost desc:
|
|
114
|
+
{project, tokens, input_tokens, output_tokens, est_cost, requests,
|
|
115
|
+
sessions: [...], subagent_tokens, subagent_runs}
|
|
116
|
+
"""
|
|
117
|
+
card = card or ILLUSTRATIVE_RATES
|
|
118
|
+
projects = {}
|
|
119
|
+
for s in sessions:
|
|
120
|
+
if not _in_window(s, since, until):
|
|
121
|
+
continue
|
|
122
|
+
key = s.get("project") or "(unknown project)"
|
|
123
|
+
p = projects.setdefault(key, {
|
|
124
|
+
"project": key,
|
|
125
|
+
"tokens": 0, "input_tokens": 0, "output_tokens": 0,
|
|
126
|
+
"est_cost": 0.0, "requests": 0,
|
|
127
|
+
"sessions": [], "subagent_tokens": 0, "subagent_runs": 0,
|
|
128
|
+
})
|
|
129
|
+
tin = s.get("input_tokens", 0)
|
|
130
|
+
tout = s.get("output_tokens", 0)
|
|
131
|
+
stin = s.get("subagent_input_tokens", 0)
|
|
132
|
+
stout = s.get("subagent_output_tokens", 0)
|
|
133
|
+
p["tokens"] += tin + tout
|
|
134
|
+
p["input_tokens"] += tin
|
|
135
|
+
p["output_tokens"] += tout
|
|
136
|
+
p["est_cost"] += _session_cost(s, card)
|
|
137
|
+
p["requests"] += s.get("requests", 0)
|
|
138
|
+
p["subagent_tokens"] += stin + stout
|
|
139
|
+
p["subagent_runs"] += s.get("subagent_runs", 0)
|
|
140
|
+
p["sessions"].append({
|
|
141
|
+
"session_id": s.get("session_id", ""),
|
|
142
|
+
"model": s.get("model", ""),
|
|
143
|
+
"tokens": tin + tout,
|
|
144
|
+
"est_cost": _session_cost(s, card),
|
|
145
|
+
"requests": s.get("requests", 0),
|
|
146
|
+
"subagent_tokens": stin + stout,
|
|
147
|
+
"subagent_runs": s.get("subagent_runs", 0),
|
|
148
|
+
})
|
|
149
|
+
result = sorted(projects.values(), key=lambda p: -p["est_cost"])
|
|
150
|
+
for p in result:
|
|
151
|
+
p["sessions"].sort(key=lambda x: -x["est_cost"])
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def by_agent(sessions, since=None, until=None, card=None, top=10):
|
|
156
|
+
"""Subagent attribution.
|
|
157
|
+
|
|
158
|
+
Returns {total_tokens, subagent_tokens, share, runs, agents: [...]}
|
|
159
|
+
where agents is a Top-N list of
|
|
160
|
+
{agent_type, tokens, share_of_subagent, runs, sessions} sorted by
|
|
161
|
+
tokens desc. A session's sidechain tokens are split across the agent
|
|
162
|
+
types it invoked, proportional to invocation counts.
|
|
163
|
+
"""
|
|
164
|
+
card = card or ILLUSTRATIVE_RATES
|
|
165
|
+
total_tokens = 0
|
|
166
|
+
subagent_tokens = 0
|
|
167
|
+
runs = 0
|
|
168
|
+
agents = {}
|
|
169
|
+
for s in sessions:
|
|
170
|
+
if not _in_window(s, since, until):
|
|
171
|
+
continue
|
|
172
|
+
tin = s.get("input_tokens", 0)
|
|
173
|
+
tout = s.get("output_tokens", 0)
|
|
174
|
+
st = s.get("subagent_input_tokens", 0) + s.get("subagent_output_tokens", 0)
|
|
175
|
+
total_tokens += tin + tout
|
|
176
|
+
subagent_tokens += st
|
|
177
|
+
types = s.get("subagent_types") or {}
|
|
178
|
+
n_runs = sum(types.values())
|
|
179
|
+
runs += n_runs
|
|
180
|
+
if st and n_runs:
|
|
181
|
+
for agent_type, count in types.items():
|
|
182
|
+
a = agents.setdefault(agent_type, {
|
|
183
|
+
"agent_type": agent_type, "tokens": 0, "runs": 0,
|
|
184
|
+
"sessions": set()})
|
|
185
|
+
a["tokens"] += st * count / n_runs
|
|
186
|
+
a["runs"] += count
|
|
187
|
+
a["sessions"].add(s.get("session_id", ""))
|
|
188
|
+
agent_list = []
|
|
189
|
+
for a in agents.values():
|
|
190
|
+
agent_list.append({
|
|
191
|
+
"agent_type": a["agent_type"],
|
|
192
|
+
"tokens": a["tokens"],
|
|
193
|
+
"share_of_subagent": a["tokens"] / subagent_tokens if subagent_tokens else 0.0,
|
|
194
|
+
"runs": a["runs"],
|
|
195
|
+
"sessions": len(a["sessions"]),
|
|
196
|
+
})
|
|
197
|
+
agent_list.sort(key=lambda a: -a["tokens"])
|
|
198
|
+
return {
|
|
199
|
+
"total_tokens": total_tokens,
|
|
200
|
+
"subagent_tokens": subagent_tokens,
|
|
201
|
+
"share": subagent_tokens / total_tokens if total_tokens else 0.0,
|
|
202
|
+
"runs": runs,
|
|
203
|
+
"agents": agent_list[:top],
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def project_report_text(projects, tokenizer, tok_factor, card=None):
|
|
208
|
+
card = card or ILLUSTRATIVE_RATES
|
|
209
|
+
L = []
|
|
210
|
+
L.append("## Per-project attribution (local Claude Code logs)")
|
|
211
|
+
L.append("Rate card: %s" % card.get("label", "built-in illustrative"))
|
|
212
|
+
L.append("Tokenizer: %s figures shown both ways; cost estimated from "
|
|
213
|
+
"measured tokens." % tokenizer)
|
|
214
|
+
L.append("")
|
|
215
|
+
if not projects:
|
|
216
|
+
L.append("No sessions found. Check --claude-dir.")
|
|
217
|
+
return "\n".join(L)
|
|
218
|
+
total_tokens = sum(p["tokens"] for p in projects)
|
|
219
|
+
total_cost = sum(p["est_cost"] for p in projects)
|
|
220
|
+
L.append("All projects: %s tokens, est. $%.2f"
|
|
221
|
+
% (dual_line(total_tokens, tokenizer, tok_factor), total_cost))
|
|
222
|
+
L.append("")
|
|
223
|
+
for p in projects:
|
|
224
|
+
share = p["est_cost"] / total_cost if total_cost else 0.0
|
|
225
|
+
sub_share = (p["subagent_tokens"] / p["tokens"]
|
|
226
|
+
if p["tokens"] else 0.0)
|
|
227
|
+
L.append("%s — %s tokens est. $%.2f (%.1f%% of total)"
|
|
228
|
+
% (p["project"], dual_line(p["tokens"], tokenizer, tok_factor),
|
|
229
|
+
p["est_cost"], share * 100))
|
|
230
|
+
L.append(" %d session(s), %d requests, subagent %.1f%% of tokens "
|
|
231
|
+
"(%d runs)"
|
|
232
|
+
% (len(p["sessions"]), p["requests"], sub_share * 100,
|
|
233
|
+
p["subagent_runs"]))
|
|
234
|
+
for s in p["sessions"][:8]:
|
|
235
|
+
L.append(" %-28.28s %-22.22s %9s tok est. $%7.2f %4d reqs"
|
|
236
|
+
% (s["session_id"][:28], s["model"][:22],
|
|
237
|
+
fmt_tokens(s["tokens"]), s["est_cost"],
|
|
238
|
+
s["requests"]))
|
|
239
|
+
if len(p["sessions"]) > 8:
|
|
240
|
+
L.append(" ... and %d more session(s)"
|
|
241
|
+
% (len(p["sessions"]) - 8))
|
|
242
|
+
L.append("")
|
|
243
|
+
return "\n".join(L)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def agent_report_text(result, tokenizer, tok_factor, card=None):
|
|
247
|
+
card = card or ILLUSTRATIVE_RATES
|
|
248
|
+
L = []
|
|
249
|
+
L.append("## Subagent attribution (local Claude Code logs)")
|
|
250
|
+
L.append("Rate card: %s" % card.get("label", "built-in illustrative"))
|
|
251
|
+
L.append("Subagent tokens are measured on sidechain transcript messages; "
|
|
252
|
+
"agent-type split is proportional to Task invocations.")
|
|
253
|
+
L.append("")
|
|
254
|
+
L.append("Subagent share of all tokens: %s tokens = %.1f%% (%d runs)"
|
|
255
|
+
% (dual_line(result["subagent_tokens"], tokenizer, tok_factor),
|
|
256
|
+
result["share"] * 100, result["runs"]))
|
|
257
|
+
L.append("Total measured tokens: %s"
|
|
258
|
+
% dual_line(result["total_tokens"], tokenizer, tok_factor))
|
|
259
|
+
L.append("")
|
|
260
|
+
if not result["agents"]:
|
|
261
|
+
L.append("No subagent activity detected. (Subagents appear as "
|
|
262
|
+
"sidechain transcript messages with Task tool calls.)")
|
|
263
|
+
return "\n".join(L)
|
|
264
|
+
L.append("Top subagent types:")
|
|
265
|
+
for i, a in enumerate(result["agents"], 1):
|
|
266
|
+
L.append(" %2d. %-24.24s %9s tok %5.1f%% of subagent %4d runs %3d sessions"
|
|
267
|
+
% (i, a["agent_type"], fmt_tokens(a["tokens"]),
|
|
268
|
+
a["share_of_subagent"] * 100, a["runs"], a["sessions"]))
|
|
269
|
+
return "\n".join(L)
|