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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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)