llm-cost-attribution 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/bin/llm-cost.mjs +494 -2
- package/package.json +1 -1
- package/src/correlate.mjs +203 -0
- package/src/cost-feature-join.mjs +393 -0
- package/src/git-diff-source.mjs +278 -0
- package/src/index.mjs +77 -0
package/README.md
CHANGED
|
@@ -94,6 +94,105 @@ llm-cost calibrate ~/backfill.out --seed 1 --holdout 0.2
|
|
|
94
94
|
|
|
95
95
|
Read-only and local — the input is never written back or committed (point it at a gitignored file). Committed tests use only synthetic fixtures (`test/forecast-recovers-known-dist.test.mjs`).
|
|
96
96
|
|
|
97
|
+
## What drives your cost? (`cost-drivers`)
|
|
98
|
+
|
|
99
|
+
`cost-drivers` runs an end-to-end correlation analysis: it reads your LLM cost records, reads diff statistics from a local git repo, joins them by issue key, and prints Spearman rank correlation, linear Pearson, log-log Pearson, and a decile table. The goal is to understand which attributes of an issue predict how much it costs — using your own data, not anyone else's benchmarks.
|
|
100
|
+
|
|
101
|
+
**Minimal inputs:** a local git repo whose commit subjects include issue keys, and transcripts (or a `usage.jsonl`) for the same issues.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
llm-cost cost-drivers --repo ~/code/my-project
|
|
105
|
+
llm-cost cost-drivers --repo ~/code/my-project --metric turns
|
|
106
|
+
llm-cost cost-drivers --repo ~/code/my-project --from-usage ~/llm-cost-history.jsonl
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Example readout (synthetic numbers — for illustration only):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
════════════════════════════════════════════════════════════════════════
|
|
113
|
+
COST DRIVERS — diff churn vs tokens
|
|
114
|
+
════════════════════════════════════════════════════════════════════════
|
|
115
|
+
Join strategy: issue
|
|
116
|
+
Source: ~/code/my-project
|
|
117
|
+
n = 42 pairs unjoined: 3 usage, 5 diffs unmatched commits: 11
|
|
118
|
+
|
|
119
|
+
Correlations:
|
|
120
|
+
Spearman 0.34
|
|
121
|
+
Pearson(linear) 0.21
|
|
122
|
+
Pearson(log-log) 0.40
|
|
123
|
+
|
|
124
|
+
Decile table:
|
|
125
|
+
Decile Feature range n Median cost
|
|
126
|
+
────────────────────────────────────────────────────────────────────────
|
|
127
|
+
1 14 – 87 4 58.3K
|
|
128
|
+
2 91 – 210 4 72.1K
|
|
129
|
+
3 215 – 380 4 91.4K
|
|
130
|
+
4 384 – 510 4 103.2K
|
|
131
|
+
5 512 – 740 5 128.7K
|
|
132
|
+
6 744 – 1.1K 4 145.3K
|
|
133
|
+
7 1.1K – 1.6K 4 189.6K
|
|
134
|
+
8 1.6K – 2.4K 4 224.1K
|
|
135
|
+
9 2.5K – 4.1K 5 301.8K
|
|
136
|
+
10 4.2K – 9.3K 4 512.4K
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Reading that block: **Feature range** is diff churn (additions + deletions) in lines; **Median cost** is the median token count for issues in that churn decile. The three correlation coefficients tell the same story from different angles — see "Reading the output" below.
|
|
140
|
+
|
|
141
|
+
### Join model
|
|
142
|
+
|
|
143
|
+
`cost-drivers` needs to know which cost record belongs to which diff. The `--join-by` flag selects the strategy:
|
|
144
|
+
|
|
145
|
+
| Strategy | How it joins | When to use |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `issue` (default) | Extracts issue keys (e.g. `ABC-123`) from commit subjects and from each cost record's `issueIdentifier` / workspace path | Works out of the box with Symphony's per-issue worktree convention and squash-merge commit messages |
|
|
148
|
+
| `worktree` | Joins on the cost record's workspace path vs. the diff record's key | Useful when your diff records carry workspace paths instead of issue keys |
|
|
149
|
+
| `time` | Attributes each cost record to the next commit within `--window` (e.g. `30m`, `2h`, `1d`) | Label-free fallback when commit subjects don't contain keys; inherently approximate |
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# explicit strategies
|
|
153
|
+
llm-cost cost-drivers --repo ~/code/my-project --join-by issue # default
|
|
154
|
+
llm-cost cost-drivers --repo ~/code/my-project --join-by worktree
|
|
155
|
+
llm-cost cost-drivers --repo ~/code/my-project --join-by time --window 2h
|
|
156
|
+
|
|
157
|
+
# override the key-extraction regex if your project uses a different format
|
|
158
|
+
llm-cost cost-drivers --repo ~/code/my-project --key-pattern 'TICKET-\d+'
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The `keyOfUsage`, `keyOfDiff`, and `join` overrides are available via the library API (`joinCostWithFeature`) for cases the CLI flags don't cover — for example joining on a custom field, or implementing a fully custom reconciliation.
|
|
162
|
+
|
|
163
|
+
#### Escape hatch: join externally with `dump-* → correlate`
|
|
164
|
+
|
|
165
|
+
If none of the built-in strategies fit, emit the two streams and join them yourself:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# 1. dump the cost stream
|
|
169
|
+
llm-cost dump-usage > usage.jsonl
|
|
170
|
+
|
|
171
|
+
# 2. dump the diff stream
|
|
172
|
+
llm-cost dump-diffs --repo ~/code/my-project > diffs.jsonl
|
|
173
|
+
|
|
174
|
+
# 3. join them however you like, then feed back a { feature, cost } CSV
|
|
175
|
+
llm-cost correlate --pairs my-pairs.csv # CSV: feature,cost[,key]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`correlate --pairs` accepts `.csv` (header `feature,cost`) or `.json` (array of `{feature, cost}` objects) and produces the same readout as `cost-drivers`.
|
|
179
|
+
|
|
180
|
+
### Reading the output
|
|
181
|
+
|
|
182
|
+
**Three correlation views, not one.** LLM cost is heavy-tailed — a handful of expensive issues can dominate a linear average. `cost-drivers` therefore reports:
|
|
183
|
+
|
|
184
|
+
- **Spearman** (rank correlation): captures monotonic relationships without being skewed by outliers. If big issues generally cost more than small ones, Spearman will pick that up even when the raw values vary wildly.
|
|
185
|
+
- **Pearson (linear)**: the standard linear correlation on raw values. On heavy-tailed data it can read near zero even when Spearman is meaningful; it is sensitive to a few extreme issues.
|
|
186
|
+
- **Pearson (log-log)**: Pearson on log₁₀-transformed values, the right view when both axes span orders of magnitude. If cost and diff size both grow geometrically, this is the coefficient that captures it.
|
|
187
|
+
|
|
188
|
+
A large gap between Spearman and linear Pearson is a signal that the relationship is real but nonlinear or that a few outliers are suppressing the linear view — not that the relationship is absent.
|
|
189
|
+
|
|
190
|
+
**Always check `n`.** With a small sample (say n < 20) the coefficients are unreliable and the decile table will have very few rows per bucket. Treat the output as directional until you have more history.
|
|
191
|
+
|
|
192
|
+
**Diff size is output, not effort.** A feature that happens to touch many files will show high churn whether or not it was the most complex work. Churn is the most readily available proxy; other features (issue estimate, turn count) may or may not track cost better on your workload.
|
|
193
|
+
|
|
194
|
+
**Local-git limits.** `readGitDiffs` only sees commits already in your local checkout — run `git fetch` or `git pull` first if you want remote-only commits. For the default `issue` strategy, commits must also carry issue keys in their subjects (the default pattern matches `ABC-123`-style keys; override with `--key-pattern`).
|
|
195
|
+
|
|
97
196
|
## Library
|
|
98
197
|
|
|
99
198
|
```js
|
|
@@ -111,6 +210,21 @@ const result = await backfillUsageFromTranscripts({ outFile: '/tmp/usage.jsonl'
|
|
|
111
210
|
|
|
112
211
|
Pass `{ cwdPattern, claudeProjectsDir, codexSessionsDir }` to override defaults.
|
|
113
212
|
|
|
213
|
+
### Diff-size feature records
|
|
214
|
+
|
|
215
|
+
`readGitDiffs(repoPath, { revRange, keyPattern })` reads local `git log --numstat`
|
|
216
|
+
output and yields one aggregated record per issue key found in commit subjects:
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
for await (const diff of readGitDiffs('/path/to/repo')) {
|
|
220
|
+
console.log(diff.key, diff.additions + diff.deletions, diff.changedFiles);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
It is local-first: no GitHub token, network, or API calls. The tradeoff is that it
|
|
225
|
+
sees only history already present in the checkout, and commits must carry issue
|
|
226
|
+
keys in their subjects, as with squash-merge subjects like `[ABC-12]: add widget`.
|
|
227
|
+
|
|
114
228
|
## What it doesn't (and can't) do
|
|
115
229
|
|
|
116
230
|
- **Story-point estimates** — live in your tracker, not the transcripts (see the sibling `llm-cost-estimation`).
|
package/bin/llm-cost.mjs
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* For any other layout, pass `--cwd-pattern` with a JavaScript regex
|
|
18
18
|
* containing one capture group for the issue identifier.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { extname, resolve } from 'node:path';
|
|
21
22
|
import { parseArgs } from 'node:util';
|
|
22
23
|
import {
|
|
23
24
|
backfillUsageFromTranscripts,
|
|
@@ -25,7 +26,11 @@ import {
|
|
|
25
26
|
computeIssueCost,
|
|
26
27
|
computeIssueCostFromUsage,
|
|
27
28
|
computeWorktreeCost,
|
|
29
|
+
correlateCostWithFeature,
|
|
30
|
+
iterateUsageFromTranscripts,
|
|
31
|
+
joinCostWithFeature,
|
|
28
32
|
listKnownIssues,
|
|
33
|
+
readGitDiffs,
|
|
29
34
|
readUsageRecords,
|
|
30
35
|
validateUsageRecord,
|
|
31
36
|
} from '../src/index.mjs';
|
|
@@ -54,6 +59,15 @@ async function main() {
|
|
|
54
59
|
holdout: { type: 'string' },
|
|
55
60
|
quantile: { type: 'string' },
|
|
56
61
|
threshold: { type: 'string' },
|
|
62
|
+
// cost-drivers / dump-* / correlate verbs
|
|
63
|
+
repo: { type: 'string' },
|
|
64
|
+
metric: { type: 'string' },
|
|
65
|
+
'join-by': { type: 'string' },
|
|
66
|
+
'key-pattern': { type: 'string' },
|
|
67
|
+
'rev-range': { type: 'string' },
|
|
68
|
+
window: { type: 'string' },
|
|
69
|
+
pairs: { type: 'string' },
|
|
70
|
+
csv: { type: 'string' },
|
|
57
71
|
json: { type: 'boolean' },
|
|
58
72
|
help: { type: 'boolean', short: 'h' },
|
|
59
73
|
},
|
|
@@ -150,6 +164,35 @@ async function main() {
|
|
|
150
164
|
return;
|
|
151
165
|
}
|
|
152
166
|
|
|
167
|
+
// `llm-cost cost-drivers --repo <path>` — end-to-end cost↔diff correlation.
|
|
168
|
+
// Reads cost from either transcripts (default) or --from-usage, reads diffs
|
|
169
|
+
// from a local git repo, joins by the chosen strategy, and prints the
|
|
170
|
+
// Spearman / Pearson / decile readout.
|
|
171
|
+
if (command === 'cost-drivers') {
|
|
172
|
+
await runCostDrivers(values, options);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// `llm-cost dump-usage` — emit the cost stream as JSONL for the user to
|
|
177
|
+
// join externally. Source: --from-usage if set; otherwise transcripts.
|
|
178
|
+
if (command === 'dump-usage') {
|
|
179
|
+
await runDumpUsage(values, options);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `llm-cost dump-diffs --repo <path>` — emit the diff stream as JSONL.
|
|
184
|
+
if (command === 'dump-diffs') {
|
|
185
|
+
await runDumpDiffs(values);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// `llm-cost correlate --pairs <file.csv|json>` — read externally-joined
|
|
190
|
+
// {feature, cost} pairs and print the readout. No git/transcripts needed.
|
|
191
|
+
if (command === 'correlate') {
|
|
192
|
+
await runCorrelate(values);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
153
196
|
if (command === 'list') {
|
|
154
197
|
const ids = await listKnownIssues(options);
|
|
155
198
|
if (values.json === true) {
|
|
@@ -342,6 +385,11 @@ function printUsage() {
|
|
|
342
385
|
llm-cost list
|
|
343
386
|
llm-cost backfill --out <usage.jsonl-path>
|
|
344
387
|
llm-cost calibrate <usage.jsonl-or-dir> [--seed N] [--holdout F]
|
|
388
|
+
llm-cost cost-drivers --repo <path> [--metric tokens|turns]
|
|
389
|
+
[--join-by issue|worktree|time] [--window <dur>]
|
|
390
|
+
llm-cost dump-usage [--from-usage <path>] [--json]
|
|
391
|
+
llm-cost dump-diffs --repo <path> [--key-pattern <regex>] [--json]
|
|
392
|
+
llm-cost correlate --pairs <file.csv|json> [--csv <out>] [--json]
|
|
345
393
|
llm-cost --help
|
|
346
394
|
|
|
347
395
|
Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions.
|
|
@@ -374,7 +422,30 @@ Options:
|
|
|
374
422
|
--quantile <0..1> (calibrate only) Quantile band to test. Default 0.8 (P80).
|
|
375
423
|
--threshold <0..1> (calibrate only) Flag a cell when coverage drifts from
|
|
376
424
|
the target by more than this. Default 0.1 (10 points).
|
|
377
|
-
--
|
|
425
|
+
--repo <path> (cost-drivers / dump-diffs only) Local git repo to read
|
|
426
|
+
diffs from. Diff size = additions + deletions.
|
|
427
|
+
--metric tokens|turns (cost-drivers only) Cost dimension to correlate. Default tokens.
|
|
428
|
+
--join-by issue|worktree|time
|
|
429
|
+
(cost-drivers only) How to attribute cost to a diff.
|
|
430
|
+
Default 'issue' (extract a key from commit subjects
|
|
431
|
+
and from each cost record's issueIdentifier/workspacePath).
|
|
432
|
+
'worktree' joins on the cost record's workspacePath.
|
|
433
|
+
'time' attributes each cost record to the next commit
|
|
434
|
+
inside --window, an approximate label-free fallback.
|
|
435
|
+
--window <duration> (cost-drivers --join-by time only) Sweep window before
|
|
436
|
+
a commit, e.g. '30m', '2h', '1d', or a millisecond integer.
|
|
437
|
+
--key-pattern <regex> (cost-drivers / dump-diffs only) Regex for issue keys in
|
|
438
|
+
commit subjects. Default \`[A-Z][A-Z0-9]+-\\d+\`.
|
|
439
|
+
--rev-range <range> (cost-drivers / dump-diffs only) Optional git rev range
|
|
440
|
+
(e.g. \`origin/main..HEAD\`).
|
|
441
|
+
--pairs <file> (correlate only) Externally-joined pairs to read.
|
|
442
|
+
.csv: header \`feature,cost[,key]\`; .json: array of
|
|
443
|
+
\`{feature, cost, key?}\` objects.
|
|
444
|
+
--csv <path> (cost-drivers / correlate only) Write per-pair rows to
|
|
445
|
+
this caller-named path. Never written to the cwd by default.
|
|
446
|
+
--json Emit machine-readable JSON instead of the table. For
|
|
447
|
+
dump-* the default is JSONL (one record per line);
|
|
448
|
+
--json packs the records into a single JSON array.
|
|
378
449
|
-h, --help Print this message.
|
|
379
450
|
|
|
380
451
|
Examples:
|
|
@@ -395,6 +466,15 @@ Examples:
|
|
|
395
466
|
# Check whether the forecaster's P80 band is actually calibrated against a
|
|
396
467
|
# local, estimate-tagged dataset. The input stays local — never committed.
|
|
397
468
|
llm-cost calibrate ~/backfill.out --seed 1 --holdout 0.2
|
|
469
|
+
|
|
470
|
+
# End-to-end: what predicts how many tokens an issue eats?
|
|
471
|
+
llm-cost cost-drivers --repo ~/code/my-repo --metric tokens
|
|
472
|
+
llm-cost cost-drivers --repo ~/code/my-repo --join-by worktree --json
|
|
473
|
+
|
|
474
|
+
# Escape hatches: emit the two streams and join them yourself.
|
|
475
|
+
llm-cost dump-usage > usage.jsonl
|
|
476
|
+
llm-cost dump-diffs --repo ~/code/my-repo > diffs.jsonl
|
|
477
|
+
llm-cost correlate --pairs my-pairs.csv # CSV: feature,cost
|
|
398
478
|
`);
|
|
399
479
|
}
|
|
400
480
|
|
|
@@ -638,6 +718,418 @@ function formatRate(perMillionUsd) {
|
|
|
638
718
|
return `$${perMillionUsd.toFixed(4)}`;
|
|
639
719
|
}
|
|
640
720
|
|
|
721
|
+
/* ------------------------------------------------------------------------- *
|
|
722
|
+
* cost-drivers / dump-* / correlate verbs
|
|
723
|
+
* ------------------------------------------------------------------------- */
|
|
724
|
+
|
|
725
|
+
const METRICS = ['tokens', 'turns'];
|
|
726
|
+
const JOIN_BY_TO_STRATEGY = { issue: 'issue-key', worktree: 'worktree', time: 'time' };
|
|
727
|
+
const JOIN_BY_CHOICES = Object.keys(JOIN_BY_TO_STRATEGY);
|
|
728
|
+
|
|
729
|
+
async function runCostDrivers(values, transcriptOptions) {
|
|
730
|
+
const repo = values.repo;
|
|
731
|
+
if (typeof repo !== 'string' || repo === '') {
|
|
732
|
+
console.error('error: cost-drivers requires --repo <path>');
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
const metric = pickMetric(values.metric);
|
|
736
|
+
const joinByLabel = pickJoinBy(values['join-by']);
|
|
737
|
+
const strategy = JOIN_BY_TO_STRATEGY[joinByLabel];
|
|
738
|
+
const keyPattern = parseRegexOption(values['key-pattern'], 'key-pattern');
|
|
739
|
+
const cwdPattern = parseRegexOption(values['cwd-pattern'], 'cwd-pattern') ?? DEFAULT_CWD_PATTERN;
|
|
740
|
+
|
|
741
|
+
const usage = values['from-usage'] !== undefined
|
|
742
|
+
? readUsageRecords(values['from-usage'])
|
|
743
|
+
: iterateUsageFromTranscripts({
|
|
744
|
+
cwdPattern,
|
|
745
|
+
claudeProjectsDir: transcriptOptions.claudeProjectsDir,
|
|
746
|
+
codexSessionsDir: transcriptOptions.codexSessionsDir,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const diffsGen = readGitDiffs(resolve(repo), {
|
|
750
|
+
keyPattern: keyPattern ?? undefined,
|
|
751
|
+
revRange: values['rev-range'] ?? undefined,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Drain the diffs into an array so we can grab the unmatched/error summary
|
|
755
|
+
// alongside the records the join consumes.
|
|
756
|
+
const diffRecords = [];
|
|
757
|
+
let diffSummary = null;
|
|
758
|
+
while (true) {
|
|
759
|
+
const step = await diffsGen.next();
|
|
760
|
+
if (step.done) { diffSummary = step.value; break; }
|
|
761
|
+
diffRecords.push(step.value);
|
|
762
|
+
}
|
|
763
|
+
if (diffSummary !== null && diffSummary.error !== null) {
|
|
764
|
+
console.error(`error: ${diffSummary.error.message}`);
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const joinArgs = {
|
|
769
|
+
usage,
|
|
770
|
+
diffs: diffRecords,
|
|
771
|
+
strategy,
|
|
772
|
+
cwdPattern,
|
|
773
|
+
};
|
|
774
|
+
if (strategy === 'time') joinArgs.window = parseDurationMs(values.window);
|
|
775
|
+
|
|
776
|
+
let joinOut;
|
|
777
|
+
try {
|
|
778
|
+
joinOut = await joinCostWithFeature(joinArgs);
|
|
779
|
+
} catch (err) {
|
|
780
|
+
console.error(`error: ${err.message}`);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Project to scalar { feature, cost } for the chosen metric.
|
|
785
|
+
const flatPairs = joinOut.pairs.map((p) => ({
|
|
786
|
+
key: p.key,
|
|
787
|
+
feature: p.feature,
|
|
788
|
+
cost: p.cost[metric],
|
|
789
|
+
}));
|
|
790
|
+
const result = correlateCostWithFeature(flatPairs);
|
|
791
|
+
|
|
792
|
+
if (values.csv !== undefined) await writeCsvPairs(values.csv, flatPairs, metric);
|
|
793
|
+
|
|
794
|
+
const unmatched = diffSummary?.unmatched ?? {};
|
|
795
|
+
const payload = {
|
|
796
|
+
metric,
|
|
797
|
+
joinBy: joinByLabel,
|
|
798
|
+
repo: resolve(repo),
|
|
799
|
+
n: result.n,
|
|
800
|
+
spearman: result.spearman,
|
|
801
|
+
pearsonLinear: result.pearsonLinear,
|
|
802
|
+
pearsonLogLog: result.pearsonLogLog,
|
|
803
|
+
pearsonLogLogDropped: result.pearsonLogLogDropped,
|
|
804
|
+
deciles: result.deciles,
|
|
805
|
+
unjoined: {
|
|
806
|
+
usage: joinOut.unjoined.usage,
|
|
807
|
+
diffs: joinOut.unjoined.diffs,
|
|
808
|
+
},
|
|
809
|
+
unmatchedCommits: unmatched.unmatchedCommits ?? 0,
|
|
810
|
+
skippedEmptyCommits: unmatched.skippedEmptyCommits ?? 0,
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
if (values.json === true) {
|
|
814
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
printCorrelationReport(payload, { repoLabel: resolve(repo) });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function runDumpUsage(values, transcriptOptions) {
|
|
821
|
+
const cwdPattern = parseRegexOption(values['cwd-pattern'], 'cwd-pattern') ?? DEFAULT_CWD_PATTERN;
|
|
822
|
+
const source = values['from-usage'] !== undefined
|
|
823
|
+
? readUsageRecords(values['from-usage'])
|
|
824
|
+
: iterateUsageFromTranscripts({
|
|
825
|
+
cwdPattern,
|
|
826
|
+
claudeProjectsDir: transcriptOptions.claudeProjectsDir,
|
|
827
|
+
codexSessionsDir: transcriptOptions.codexSessionsDir,
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
if (values.json === true) {
|
|
831
|
+
const all = [];
|
|
832
|
+
for await (const rec of source) all.push(rec);
|
|
833
|
+
process.stdout.write(JSON.stringify(all) + '\n');
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
for await (const rec of source) {
|
|
837
|
+
process.stdout.write(JSON.stringify(rec) + '\n');
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function runDumpDiffs(values) {
|
|
842
|
+
const repo = values.repo;
|
|
843
|
+
if (typeof repo !== 'string' || repo === '') {
|
|
844
|
+
console.error('error: dump-diffs requires --repo <path>');
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
const keyPattern = parseRegexOption(values['key-pattern'], 'key-pattern');
|
|
848
|
+
const diffsGen = readGitDiffs(resolve(repo), {
|
|
849
|
+
keyPattern: keyPattern ?? undefined,
|
|
850
|
+
revRange: values['rev-range'] ?? undefined,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const records = [];
|
|
854
|
+
let summary = null;
|
|
855
|
+
while (true) {
|
|
856
|
+
const step = await diffsGen.next();
|
|
857
|
+
if (step.done) { summary = step.value; break; }
|
|
858
|
+
records.push(step.value);
|
|
859
|
+
}
|
|
860
|
+
if (summary !== null && summary.error !== null) {
|
|
861
|
+
console.error(`error: ${summary.error.message}`);
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (values.json === true) {
|
|
866
|
+
process.stdout.write(JSON.stringify(records) + '\n');
|
|
867
|
+
} else {
|
|
868
|
+
for (const rec of records) process.stdout.write(JSON.stringify(rec) + '\n');
|
|
869
|
+
}
|
|
870
|
+
const u = summary?.unmatched ?? {};
|
|
871
|
+
process.stderr.write(
|
|
872
|
+
`# ${records.length} records, ${u.unmatchedCommits ?? 0} unmatched commit${(u.unmatchedCommits ?? 0) === 1 ? '' : 's'}, ` +
|
|
873
|
+
`${u.skippedEmptyCommits ?? 0} skipped empty\n`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function runCorrelate(values) {
|
|
878
|
+
if (values.pairs === undefined || values.pairs === '') {
|
|
879
|
+
console.error('error: correlate requires --pairs <file.csv|json>');
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
let pairs;
|
|
883
|
+
try {
|
|
884
|
+
pairs = await readPairsFile(values.pairs);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
console.error(`error: ${err.message}`);
|
|
887
|
+
process.exit(1);
|
|
888
|
+
}
|
|
889
|
+
const result = correlateCostWithFeature(pairs);
|
|
890
|
+
if (values.csv !== undefined) await writeCsvPairs(values.csv, pairs, 'cost');
|
|
891
|
+
|
|
892
|
+
const payload = {
|
|
893
|
+
source: resolve(values.pairs),
|
|
894
|
+
n: result.n,
|
|
895
|
+
spearman: result.spearman,
|
|
896
|
+
pearsonLinear: result.pearsonLinear,
|
|
897
|
+
pearsonLogLog: result.pearsonLogLog,
|
|
898
|
+
pearsonLogLogDropped: result.pearsonLogLogDropped,
|
|
899
|
+
deciles: result.deciles,
|
|
900
|
+
};
|
|
901
|
+
if (values.json === true) {
|
|
902
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
printCorrelationReport(payload, { repoLabel: resolve(values.pairs) });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* ------------------------------------------------------------------------- *
|
|
909
|
+
* helpers for cost-drivers / correlate
|
|
910
|
+
* ------------------------------------------------------------------------- */
|
|
911
|
+
|
|
912
|
+
function pickMetric(raw) {
|
|
913
|
+
if (raw === undefined) return 'tokens';
|
|
914
|
+
if (!METRICS.includes(raw)) {
|
|
915
|
+
console.error(`error: --metric must be one of ${METRICS.join(', ')} (got "${raw}")`);
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
return raw;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function pickJoinBy(raw) {
|
|
922
|
+
if (raw === undefined) return 'issue';
|
|
923
|
+
if (!JOIN_BY_CHOICES.includes(raw)) {
|
|
924
|
+
console.error(`error: --join-by must be one of ${JOIN_BY_CHOICES.join(', ')} (got "${raw}")`);
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
927
|
+
return raw;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function parseRegexOption(raw, name) {
|
|
931
|
+
if (raw === undefined) return null;
|
|
932
|
+
try {
|
|
933
|
+
return new RegExp(raw);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
console.error(`error: --${name} is not a valid regex: ${err.message}`);
|
|
936
|
+
process.exit(1);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Accept either a plain ms integer or a suffixed duration: 30s, 2m, 1h, 3d. */
|
|
941
|
+
function parseDurationMs(raw) {
|
|
942
|
+
if (raw === undefined) {
|
|
943
|
+
console.error("error: --join-by time requires --window (e.g. '30m', '2h', '1d', or a millisecond integer)");
|
|
944
|
+
process.exit(1);
|
|
945
|
+
}
|
|
946
|
+
const match = String(raw).trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
|
|
947
|
+
if (match === null) {
|
|
948
|
+
console.error(`error: --window must be a number with optional suffix ms/s/m/h/d (got "${raw}")`);
|
|
949
|
+
process.exit(1);
|
|
950
|
+
}
|
|
951
|
+
const value = Number(match[1]);
|
|
952
|
+
const unit = match[2] ?? 'ms';
|
|
953
|
+
const factor = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
|
|
954
|
+
return value * factor;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function readPairsFile(path) {
|
|
958
|
+
const ext = extname(path).toLowerCase();
|
|
959
|
+
const text = await readFile(path, 'utf8');
|
|
960
|
+
if (ext === '.json') return parsePairsJson(text, path);
|
|
961
|
+
if (ext === '.csv') return parsePairsCsv(text, path);
|
|
962
|
+
// Fallback: sniff content.
|
|
963
|
+
const trimmed = text.trimStart();
|
|
964
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{')) return parsePairsJson(text, path);
|
|
965
|
+
return parsePairsCsv(text, path);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function parsePairsJson(text, path) {
|
|
969
|
+
let data;
|
|
970
|
+
try {
|
|
971
|
+
data = JSON.parse(text);
|
|
972
|
+
} catch (err) {
|
|
973
|
+
throw new Error(`${path}: not valid JSON (${err.message})`);
|
|
974
|
+
}
|
|
975
|
+
if (!Array.isArray(data)) throw new Error(`${path}: expected an array of {feature, cost} objects`);
|
|
976
|
+
const out = [];
|
|
977
|
+
for (let i = 0; i < data.length; i++) {
|
|
978
|
+
const row = data[i];
|
|
979
|
+
if (row === null || typeof row !== 'object') {
|
|
980
|
+
throw new Error(`${path}: row ${i} is not an object`);
|
|
981
|
+
}
|
|
982
|
+
const feature = Number(row.feature);
|
|
983
|
+
const cost = Number(row.cost);
|
|
984
|
+
if (!Number.isFinite(feature)) throw new Error(`${path}: row ${i} has non-numeric feature`);
|
|
985
|
+
if (!Number.isFinite(cost)) throw new Error(`${path}: row ${i} has non-numeric cost`);
|
|
986
|
+
const pair = { feature, cost };
|
|
987
|
+
if (typeof row.key === 'string') pair.key = row.key;
|
|
988
|
+
out.push(pair);
|
|
989
|
+
}
|
|
990
|
+
return out;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function parsePairsCsv(text, path) {
|
|
994
|
+
const lines = text.split(/\r?\n/).filter((l) => l !== '');
|
|
995
|
+
if (lines.length === 0) return [];
|
|
996
|
+
const header = splitCsvLine(lines[0]);
|
|
997
|
+
const featureIdx = header.findIndex((h) => h.toLowerCase() === 'feature');
|
|
998
|
+
const costIdx = header.findIndex((h) => h.toLowerCase() === 'cost');
|
|
999
|
+
const keyIdx = header.findIndex((h) => h.toLowerCase() === 'key');
|
|
1000
|
+
if (featureIdx === -1 || costIdx === -1) {
|
|
1001
|
+
throw new Error(`${path}: CSV must have header columns "feature" and "cost"`);
|
|
1002
|
+
}
|
|
1003
|
+
const out = [];
|
|
1004
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1005
|
+
const fields = splitCsvLine(lines[i]);
|
|
1006
|
+
const feature = Number(fields[featureIdx]);
|
|
1007
|
+
const cost = Number(fields[costIdx]);
|
|
1008
|
+
if (!Number.isFinite(feature) || !Number.isFinite(cost)) continue;
|
|
1009
|
+
const pair = { feature, cost };
|
|
1010
|
+
if (keyIdx !== -1 && fields[keyIdx] !== undefined) pair.key = fields[keyIdx];
|
|
1011
|
+
out.push(pair);
|
|
1012
|
+
}
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function splitCsvLine(line) {
|
|
1017
|
+
const out = [];
|
|
1018
|
+
let cur = '';
|
|
1019
|
+
let inQuotes = false;
|
|
1020
|
+
for (let i = 0; i < line.length; i++) {
|
|
1021
|
+
const ch = line[i];
|
|
1022
|
+
if (inQuotes) {
|
|
1023
|
+
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i += 1; continue; }
|
|
1024
|
+
if (ch === '"') { inQuotes = false; continue; }
|
|
1025
|
+
cur += ch;
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
1029
|
+
if (ch === ',') { out.push(cur); cur = ''; continue; }
|
|
1030
|
+
cur += ch;
|
|
1031
|
+
}
|
|
1032
|
+
out.push(cur);
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function writeCsvPairs(path, pairs, costLabel) {
|
|
1037
|
+
const hasKeyCol = pairs.some((p) => typeof p.key === 'string');
|
|
1038
|
+
const header = hasKeyCol ? `key,feature,${costLabel}` : `feature,${costLabel}`;
|
|
1039
|
+
const rows = pairs.map((p) => {
|
|
1040
|
+
const f = String(p.feature);
|
|
1041
|
+
const c = String(p.cost);
|
|
1042
|
+
if (!hasKeyCol) return `${f},${c}`;
|
|
1043
|
+
return `${csvField(p.key ?? '')},${f},${c}`;
|
|
1044
|
+
});
|
|
1045
|
+
await writeFile(path, header + '\n' + rows.join('\n') + (rows.length > 0 ? '\n' : ''), 'utf8');
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function csvField(value) {
|
|
1049
|
+
const s = String(value);
|
|
1050
|
+
if (/[",\r\n]/.test(s)) return `"${s.replaceAll('"', '""')}"`;
|
|
1051
|
+
return s;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function printCorrelationReport(payload, { repoLabel }) {
|
|
1055
|
+
const fmtCoef = (v) => (v === null ? ' —' : (v >= 0 ? ` ${v.toFixed(3)}` : v.toFixed(3)));
|
|
1056
|
+
const fmtCost = (n) => {
|
|
1057
|
+
if (!Number.isFinite(n)) return ' —';
|
|
1058
|
+
if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
1059
|
+
if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
1060
|
+
return n.toFixed(n >= 100 || Number.isInteger(n) ? 0 : 2);
|
|
1061
|
+
};
|
|
1062
|
+
const fmtFeat = (n) => {
|
|
1063
|
+
if (!Number.isFinite(n)) return '—';
|
|
1064
|
+
if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
1065
|
+
if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
1066
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(2);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
const heading = payload.metric !== undefined
|
|
1070
|
+
? `COST DRIVERS — diff size vs ${payload.metric}`
|
|
1071
|
+
: `COST DRIVERS — external pairs`;
|
|
1072
|
+
|
|
1073
|
+
console.log(HEAD);
|
|
1074
|
+
console.log(heading);
|
|
1075
|
+
console.log(HEAD);
|
|
1076
|
+
if (payload.joinBy !== undefined) console.log(`Join strategy: ${payload.joinBy}`);
|
|
1077
|
+
console.log(`Source: ${repoLabel}`);
|
|
1078
|
+
const unjoinedUsage = payload.unjoined?.usage?.length ?? 0;
|
|
1079
|
+
const unjoinedDiffs = payload.unjoined?.diffs?.length ?? 0;
|
|
1080
|
+
if (payload.joinBy !== undefined) {
|
|
1081
|
+
console.log(
|
|
1082
|
+
`n = ${payload.n} pair${payload.n === 1 ? '' : 's'} ` +
|
|
1083
|
+
`unjoined: ${unjoinedUsage} usage, ${unjoinedDiffs} diffs ` +
|
|
1084
|
+
`unmatched commits: ${payload.unmatchedCommits}`,
|
|
1085
|
+
);
|
|
1086
|
+
} else {
|
|
1087
|
+
console.log(`n = ${payload.n} pair${payload.n === 1 ? '' : 's'}`);
|
|
1088
|
+
}
|
|
1089
|
+
console.log();
|
|
1090
|
+
|
|
1091
|
+
console.log('Correlations:');
|
|
1092
|
+
console.log(` Spearman ${fmtCoef(payload.spearman)}`);
|
|
1093
|
+
console.log(` Pearson(linear) ${fmtCoef(payload.pearsonLinear)}`);
|
|
1094
|
+
const ll = payload.pearsonLogLogDropped > 0
|
|
1095
|
+
? `${fmtCoef(payload.pearsonLogLog)} (${payload.pearsonLogLogDropped} pair${payload.pearsonLogLogDropped === 1 ? '' : 's'} dropped: non-positive)`
|
|
1096
|
+
: fmtCoef(payload.pearsonLogLog);
|
|
1097
|
+
console.log(` Pearson(log-log) ${ll}`);
|
|
1098
|
+
|
|
1099
|
+
if (payload.deciles.length === 0) {
|
|
1100
|
+
console.log();
|
|
1101
|
+
console.log('Decile table: not enough pairs.');
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
console.log();
|
|
1105
|
+
const medianLabel = payload.metric === 'turns' ? 'Median turns' : 'Median tokens';
|
|
1106
|
+
console.log('Decile table:');
|
|
1107
|
+
console.log(
|
|
1108
|
+
padRight('Decile', 7) +
|
|
1109
|
+
' ' + padRight('Lines changed', 24) +
|
|
1110
|
+
' ' + padLeft('n', 4) +
|
|
1111
|
+
' ' + padLeft(medianLabel, 13),
|
|
1112
|
+
);
|
|
1113
|
+
console.log(SEP);
|
|
1114
|
+
for (let i = 0; i < payload.deciles.length; i++) {
|
|
1115
|
+
const d = payload.deciles[i];
|
|
1116
|
+
const range = `${fmtFeat(d.featureRange.min)} – ${fmtFeat(d.featureRange.max)}`;
|
|
1117
|
+
console.log(
|
|
1118
|
+
padRight(String(i + 1), 7) +
|
|
1119
|
+
' ' + padRight(range, 24) +
|
|
1120
|
+
' ' + padLeft(String(d.n), 4) +
|
|
1121
|
+
' ' + padLeft(fmtCost(d.medianCost), 13),
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (unjoinedUsage > 0 || unjoinedDiffs > 0) {
|
|
1126
|
+
console.log();
|
|
1127
|
+
console.log(
|
|
1128
|
+
`Note: ${unjoinedUsage} usage key${unjoinedUsage === 1 ? '' : 's'} and ${unjoinedDiffs} diff key${unjoinedDiffs === 1 ? '' : 's'} did not join.`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
641
1133
|
main().catch((err) => {
|
|
642
1134
|
console.error(err);
|
|
643
1135
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cost-attribution",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions. Reads the CLIs' own session JSONLs — no telemetry pipeline required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|