llm-cost-attribution 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +204 -0
- package/bin/llm-cost.mjs +475 -0
- package/package.json +41 -0
- package/src/aggregator.mjs +75 -0
- package/src/index.mjs +243 -0
- package/src/issue-pattern.mjs +58 -0
- package/src/multi-issue.mjs +217 -0
- package/src/pricing-rates.mjs +140 -0
- package/src/pricing.mjs +157 -0
- package/src/quota.mjs +57 -0
- package/src/transcript-to-usage.mjs +120 -0
- package/src/transcripts/claude.mjs +99 -0
- package/src/transcripts/codex.mjs +145 -0
- package/src/usage-aggregator.mjs +114 -0
- package/src/usage-jsonl.mjs +134 -0
- package/src/util.mjs +48 -0
package/bin/llm-cost.mjs
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `llm-cost` — per-issue token, turn, and quota analytics for Claude Code
|
|
4
|
+
* and Codex CLI sessions.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* llm-cost <ISSUE-ID>
|
|
8
|
+
* llm-cost <ISSUE-ID> --cwd-pattern '<regex>'
|
|
9
|
+
* llm-cost list
|
|
10
|
+
* llm-cost --help
|
|
11
|
+
*
|
|
12
|
+
* Reads from `~/.claude/projects` and `~/.codex/sessions`. The default
|
|
13
|
+
* cwd-to-issue regex matches the Symphony spec's per-issue workspace
|
|
14
|
+
* convention (https://github.com/openai/symphony/blob/main/SPEC.md §4.1.4),
|
|
15
|
+
* covering both the default `<system-temp>/symphony_workspaces/<ID>` and
|
|
16
|
+
* the common `<repo>/.symphony/workspaces/<ID>` `workspace.root` settings.
|
|
17
|
+
* For any other layout, pass `--cwd-pattern` with a JavaScript regex
|
|
18
|
+
* containing one capture group for the issue identifier.
|
|
19
|
+
*/
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
import { parseArgs } from 'node:util';
|
|
22
|
+
import {
|
|
23
|
+
backfillUsageFromTranscripts,
|
|
24
|
+
computeIssueCost,
|
|
25
|
+
computeIssueCostFromUsage,
|
|
26
|
+
computeWorktreeCost,
|
|
27
|
+
listKnownIssues,
|
|
28
|
+
} from '../src/index.mjs';
|
|
29
|
+
import { DEFAULT_CWD_PATTERN } from '../src/issue-pattern.mjs';
|
|
30
|
+
import { computeMultiIssueRollup, expandAllIssueArgs } from '../src/multi-issue.mjs';
|
|
31
|
+
import {
|
|
32
|
+
calculateCost,
|
|
33
|
+
daysSincePricingVerified,
|
|
34
|
+
hypotheticalNoteFor,
|
|
35
|
+
isPricingStale,
|
|
36
|
+
} from '../src/pricing.mjs';
|
|
37
|
+
import { formatDuration, formatNumber } from '../src/util.mjs';
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
const { values, positionals } = parseArgs({
|
|
41
|
+
allowPositionals: true,
|
|
42
|
+
options: {
|
|
43
|
+
'cwd-pattern': { type: 'string' },
|
|
44
|
+
'claude-dir': { type: 'string' },
|
|
45
|
+
'codex-dir': { type: 'string' },
|
|
46
|
+
'from-usage': { type: 'string' },
|
|
47
|
+
'no-pricing': { type: 'boolean' },
|
|
48
|
+
worktree: { type: 'string' },
|
|
49
|
+
out: { type: 'string' },
|
|
50
|
+
json: { type: 'boolean' },
|
|
51
|
+
help: { type: 'boolean', short: 'h' },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (values.help === true || (positionals.length === 0 && values.worktree === undefined)) {
|
|
56
|
+
printUsage();
|
|
57
|
+
process.exit(values.help === true ? 0 : 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cwdPattern = values['cwd-pattern'] !== undefined
|
|
61
|
+
? new RegExp(values['cwd-pattern'])
|
|
62
|
+
: DEFAULT_CWD_PATTERN;
|
|
63
|
+
const options = { cwdPattern };
|
|
64
|
+
if (values['claude-dir'] !== undefined) options.claudeProjectsDir = values['claude-dir'];
|
|
65
|
+
if (values['codex-dir'] !== undefined) options.codexSessionsDir = values['codex-dir'];
|
|
66
|
+
|
|
67
|
+
const withPricing = values['no-pricing'] !== true;
|
|
68
|
+
|
|
69
|
+
// `llm-cost --worktree <path>` — attribute cost to a directory directly,
|
|
70
|
+
// with no issue identifier or Symphony convention required.
|
|
71
|
+
if (values.worktree !== undefined) {
|
|
72
|
+
const worktreePath = resolve(values.worktree);
|
|
73
|
+
const rollup = await computeWorktreeCost(worktreePath, options);
|
|
74
|
+
if (values.json === true) {
|
|
75
|
+
if (withPricing) attachPricingToRollup(rollup);
|
|
76
|
+
console.log(JSON.stringify(rollup, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
printRollup(rollup, false, withPricing);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const command = positionals[0];
|
|
84
|
+
|
|
85
|
+
// `llm-cost backfill --out <path>` walks transcripts and writes spec-compliant usage.jsonl.
|
|
86
|
+
if (command === 'backfill') {
|
|
87
|
+
if (values.out === undefined || values.out === '') {
|
|
88
|
+
console.error('error: backfill requires --out <path>');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
process.stderr.write(`> backfilling usage.jsonl to ${values.out} ...\n`);
|
|
92
|
+
const result = await backfillUsageFromTranscripts({
|
|
93
|
+
...options,
|
|
94
|
+
outFile: values.out,
|
|
95
|
+
onProgress: (p) => {
|
|
96
|
+
process.stderr.write(` ${p.phase}: ${p.processed}/${p.total} (${p.recordsWritten} records written)\r`);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
process.stderr.write('\n');
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`Wrote ${result.recordsWritten} usage records from ${result.sessionsProcessed} sessions ` +
|
|
102
|
+
`(${result.sessionsSkipped} sessions skipped).\n`,
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (command === 'list') {
|
|
108
|
+
const ids = await listKnownIssues(options);
|
|
109
|
+
if (values.json === true) {
|
|
110
|
+
console.log(JSON.stringify(ids, null, 2));
|
|
111
|
+
} else {
|
|
112
|
+
for (const id of ids) console.log(id);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Default: treat positionals as issue identifiers (and/or inclusive ranges
|
|
118
|
+
// like EPAC-1990-1999) and produce the appropriate rollup.
|
|
119
|
+
const fromUsage = values['from-usage'];
|
|
120
|
+
|
|
121
|
+
let expanded;
|
|
122
|
+
try {
|
|
123
|
+
expanded = expandAllIssueArgs(positionals);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`error: ${err.message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
if (expanded.ids.length === 0) {
|
|
129
|
+
console.error('error: no issue IDs supplied');
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const loadOne = async (id) => fromUsage !== undefined
|
|
134
|
+
? await computeIssueCostFromUsage(id, fromUsage)
|
|
135
|
+
: await computeIssueCost(id, options);
|
|
136
|
+
|
|
137
|
+
// Single issue → existing per-issue output (unchanged shape).
|
|
138
|
+
if (expanded.ids.length === 1) {
|
|
139
|
+
const rollup = await loadOne(expanded.ids[0]);
|
|
140
|
+
if (values.json === true) {
|
|
141
|
+
if (withPricing) attachPricingToRollup(rollup);
|
|
142
|
+
console.log(JSON.stringify(rollup, null, 2));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
printRollup(rollup, fromUsage !== undefined, withPricing);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Multiple issues (explicit list and/or expanded range) → summary table.
|
|
150
|
+
const multi = await computeMultiIssueRollup(positionals, async (id) => {
|
|
151
|
+
const rollup = await loadOne(id);
|
|
152
|
+
if (withPricing) attachPricingToRollup(rollup);
|
|
153
|
+
return rollup;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (values.json === true) {
|
|
157
|
+
console.log(JSON.stringify(multi, null, 2));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
printMultiIssueRollup(multi, fromUsage !== undefined, withPricing);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function attachPricingToRollup(rollup) {
|
|
164
|
+
for (const provider of ['claude', 'codex']) {
|
|
165
|
+
const totals = rollup.providerTotals[provider];
|
|
166
|
+
if (totals.sessionCount === 0) continue;
|
|
167
|
+
// Prefer the first model with known rates. Synthetic markers like
|
|
168
|
+
// `<synthetic>` (Claude session-restart placeholder) sort alphabetically
|
|
169
|
+
// before real model names, so don't blindly take models[0].
|
|
170
|
+
let cost = null;
|
|
171
|
+
for (const model of totals.models) {
|
|
172
|
+
cost = calculateCost(model, totals.tokens);
|
|
173
|
+
if (cost !== null) break;
|
|
174
|
+
}
|
|
175
|
+
if (cost === null) continue;
|
|
176
|
+
totals.pricing = cost;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function printUsage() {
|
|
181
|
+
console.log(`Usage: llm-cost <ISSUE-ID>... [options]
|
|
182
|
+
llm-cost <PREFIX-START-END>... [options] (range, e.g. EPAC-1990-1999)
|
|
183
|
+
llm-cost --worktree <path> (any directory, no issue ID needed)
|
|
184
|
+
llm-cost <ISSUE-ID> --from-usage <usage.jsonl-or-dir>
|
|
185
|
+
llm-cost list
|
|
186
|
+
llm-cost backfill --out <usage.jsonl-path>
|
|
187
|
+
llm-cost --help
|
|
188
|
+
|
|
189
|
+
Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions.
|
|
190
|
+
|
|
191
|
+
Sources:
|
|
192
|
+
By default, reads ~/.claude/projects and ~/.codex/sessions (the CLI's own
|
|
193
|
+
transcripts). Pass --from-usage to read from a Symphony Coding-Agent Cost
|
|
194
|
+
Telemetry Extension usage.jsonl file or directory instead — useful after
|
|
195
|
+
you've backfilled and deleted the transcripts.
|
|
196
|
+
|
|
197
|
+
Options:
|
|
198
|
+
--worktree <path> Show cost for all sessions run from this directory.
|
|
199
|
+
No issue ID or Symphony convention required — just
|
|
200
|
+
point it at the worktree the agent ran in.
|
|
201
|
+
--cwd-pattern <regex> Regex matching the cwd, with one capture group for the
|
|
202
|
+
issue identifier. Default matches Symphony's
|
|
203
|
+
\`<workspace.root>/<ISSUE-ID>\` convention (spec default
|
|
204
|
+
\`symphony_workspaces/<ID>\` and the common in-repo
|
|
205
|
+
\`.symphony/workspaces/<ID>\` form).
|
|
206
|
+
--claude-dir <path> Override ~/.claude/projects.
|
|
207
|
+
--codex-dir <path> Override ~/.codex/sessions.
|
|
208
|
+
--from-usage <path> Read from a usage.jsonl file or directory of
|
|
209
|
+
\`usage*.jsonl\` files (per the cost-telemetry spec)
|
|
210
|
+
instead of from the CLI transcripts.
|
|
211
|
+
--out <path> (backfill only) Destination usage.jsonl path. Appended.
|
|
212
|
+
--json Emit machine-readable JSON instead of the table.
|
|
213
|
+
-h, --help Print this message.
|
|
214
|
+
|
|
215
|
+
Examples:
|
|
216
|
+
llm-cost EPAC-1940
|
|
217
|
+
llm-cost EPAC-1940 EPAC-1921 FAC-67 # multiple issues, summary table
|
|
218
|
+
llm-cost EPAC-1990-1999 # inclusive range (10 issues)
|
|
219
|
+
llm-cost EPAC-1990-1999 FAC-60-70 # mix of ranges
|
|
220
|
+
llm-cost EPAC-1940 --json | jq .providerTotals.codex.quotaSamples
|
|
221
|
+
llm-cost list | grep EPAC
|
|
222
|
+
llm-cost EPAC-1940 --cwd-pattern '/issues/([A-Z]+-\\d+)$'
|
|
223
|
+
llm-cost --worktree ~/code/my-repo/.worktrees/my-feature
|
|
224
|
+
|
|
225
|
+
# Bake every transcript on this machine into a usage.jsonl, then it's safe
|
|
226
|
+
# to rm -rf ~/.claude/projects and ~/.codex/sessions.
|
|
227
|
+
llm-cost backfill --out ~/llm-cost-history.jsonl
|
|
228
|
+
llm-cost EPAC-1940 --from-usage ~/llm-cost-history.jsonl
|
|
229
|
+
`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const HEAD = '═'.repeat(72);
|
|
233
|
+
const SEP = '─'.repeat(72);
|
|
234
|
+
|
|
235
|
+
function printMultiIssueRollup(multi, fromUsageJsonl = false, withPricing = true) {
|
|
236
|
+
const hadDataCount = multi.issues.length;
|
|
237
|
+
const headerSrc = fromUsageJsonl ? ' (source: usage.jsonl)' : '';
|
|
238
|
+
console.log(HEAD);
|
|
239
|
+
console.log(`COST ROLLUP — ${multi.label}${headerSrc}`);
|
|
240
|
+
console.log(HEAD);
|
|
241
|
+
console.log(
|
|
242
|
+
`${multi.requestedCount} issue${multi.requestedCount === 1 ? '' : 's'} requested, ` +
|
|
243
|
+
`${hadDataCount} had data` +
|
|
244
|
+
(multi.requestedCount !== multi.requestedIds.length
|
|
245
|
+
? ` (${multi.requestedCount - multi.requestedIds.length} duplicates dropped)`
|
|
246
|
+
: ''),
|
|
247
|
+
);
|
|
248
|
+
console.log();
|
|
249
|
+
if (hadDataCount === 0) {
|
|
250
|
+
console.log('No requested issues had any recorded sessions.');
|
|
251
|
+
if (multi.missing.length > 0) {
|
|
252
|
+
console.log();
|
|
253
|
+
console.log(`Missing: ${multi.missing.join(', ')}`);
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Determine widest issue-id for column alignment.
|
|
259
|
+
const idWidth = Math.max(
|
|
260
|
+
multi.totals.issueIdentifier.length,
|
|
261
|
+
...multi.issues.map((r) => r.issueIdentifier.length),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Header.
|
|
265
|
+
console.log(
|
|
266
|
+
padRight('Issue', idWidth) +
|
|
267
|
+
' ' + padLeft('Sessions', 9) +
|
|
268
|
+
' ' + padLeft('Turns', 8) +
|
|
269
|
+
' ' + padLeft('Tokens', 12) +
|
|
270
|
+
' ' + padLeft('API cost', 10),
|
|
271
|
+
);
|
|
272
|
+
console.log(SEP);
|
|
273
|
+
for (const row of multi.issues) {
|
|
274
|
+
console.log(formatRow(row, idWidth));
|
|
275
|
+
}
|
|
276
|
+
console.log(SEP);
|
|
277
|
+
console.log(formatRow(multi.totals, idWidth));
|
|
278
|
+
|
|
279
|
+
if (multi.missing.length > 0) {
|
|
280
|
+
console.log();
|
|
281
|
+
if (multi.missing.length <= 8) {
|
|
282
|
+
console.log(`(skipped: ${multi.missing.join(', ')} — no sessions)`);
|
|
283
|
+
} else {
|
|
284
|
+
const shown = multi.missing.slice(0, 8).join(', ');
|
|
285
|
+
console.log(`(skipped: ${shown}, and ${multi.missing.length - 8} more — no sessions)`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (withPricing && isPricingStale()) {
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(` ⚠ Pricing table is ${daysSincePricingVerified()} days old — rates may be stale.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function formatRow(row, idWidth) {
|
|
296
|
+
return (
|
|
297
|
+
padRight(row.issueIdentifier, idWidth) +
|
|
298
|
+
' ' + padLeft(String(row.sessionCount), 9) +
|
|
299
|
+
' ' + padLeft(formatNumber(row.turnCount), 8) +
|
|
300
|
+
' ' + padLeft(formatTokensCompact(row.tokens), 12) +
|
|
301
|
+
' ' + padLeft(formatUsdOrDash(row.apiCostUsd), 10)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatUsdOrDash(usd) {
|
|
306
|
+
if (usd === null || usd === undefined) return ' —';
|
|
307
|
+
return formatUsd(usd);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function padRight(s, width) {
|
|
311
|
+
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function padLeft(s, width) {
|
|
315
|
+
return s.length >= width ? s : ' '.repeat(width - s.length) + s;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function printRollup(rollup, fromUsageJsonl = false, withPricing = true) {
|
|
319
|
+
console.log(HEAD);
|
|
320
|
+
console.log(`LLM COST — ${rollup.issueIdentifier}${fromUsageJsonl ? ' (source: usage.jsonl)' : ''}`);
|
|
321
|
+
console.log(HEAD);
|
|
322
|
+
console.log(`Sessions found: ${rollup.combinedSessions}`);
|
|
323
|
+
console.log(`Total turns: ${formatNumber(rollup.combinedTurns)}`);
|
|
324
|
+
console.log(`Total tokens: ${formatNumber(rollup.combinedTokens)}`);
|
|
325
|
+
console.log();
|
|
326
|
+
|
|
327
|
+
printProvider('CLAUDE', rollup.providerTotals.claude, withPricing);
|
|
328
|
+
console.log();
|
|
329
|
+
printProvider('CODEX', rollup.providerTotals.codex, withPricing);
|
|
330
|
+
|
|
331
|
+
if (withPricing && isPricingStale()) {
|
|
332
|
+
console.log();
|
|
333
|
+
console.log(` ⚠ Pricing table is ${daysSincePricingVerified()} days old — rates may be stale.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function printProvider(label, totals, withPricing = true) {
|
|
338
|
+
console.log(SEP);
|
|
339
|
+
console.log(`${label} (${totals.sessionCount} session${totals.sessionCount === 1 ? '' : 's'})`);
|
|
340
|
+
console.log(SEP);
|
|
341
|
+
|
|
342
|
+
if (totals.sessionCount === 0) {
|
|
343
|
+
console.log(' No sessions found for this issue.');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
console.log(` Models: ${totals.models.join(', ') || '(none recorded)'}`);
|
|
348
|
+
console.log(` Turns: ${formatNumber(totals.turnCount)}`);
|
|
349
|
+
console.log(` First → last: ${totals.firstTimestamp ?? '-'} → ${totals.lastTimestamp ?? '-'}`);
|
|
350
|
+
console.log(` Wall clock span: ${formatDuration(spanMs(totals.firstTimestamp, totals.lastTimestamp))}`);
|
|
351
|
+
console.log();
|
|
352
|
+
console.log(' Tokens:');
|
|
353
|
+
console.log(` input uncached ${pad(formatNumber(totals.tokens.inputUncached), 14)}`);
|
|
354
|
+
console.log(` cache read ${pad(formatNumber(totals.tokens.inputCached), 14)}`);
|
|
355
|
+
if (label === 'CLAUDE') {
|
|
356
|
+
console.log(` cache create 5m ${pad(formatNumber(totals.tokens.cacheCreate5m), 14)}`);
|
|
357
|
+
console.log(` cache create 1h ${pad(formatNumber(totals.tokens.cacheCreate1h), 14)}`);
|
|
358
|
+
}
|
|
359
|
+
console.log(` output (visible) ${pad(formatNumber(totals.tokens.outputVisible), 14)}`);
|
|
360
|
+
if (label === 'CODEX') {
|
|
361
|
+
console.log(` output (reasoning) ${pad(formatNumber(totals.tokens.outputReasoning), 14)}`);
|
|
362
|
+
}
|
|
363
|
+
console.log(` ─────────────────────────────────`);
|
|
364
|
+
console.log(` grand total ${pad(formatNumber(totals.tokensGrandTotal), 14)}`);
|
|
365
|
+
|
|
366
|
+
// Pricing block — API-equivalent dollar cost per bucket.
|
|
367
|
+
if (withPricing) {
|
|
368
|
+
const model = totals.models[0];
|
|
369
|
+
const cost = typeof model === 'string' && model !== '' ? calculateCost(model, totals.tokens) : null;
|
|
370
|
+
console.log();
|
|
371
|
+
if (cost === null) {
|
|
372
|
+
console.log(` API-equivalent pricing: no rates for model "${model ?? '<unknown>'}"`);
|
|
373
|
+
} else {
|
|
374
|
+
const provider = label.toLowerCase();
|
|
375
|
+
const planType = totals.quotaSamples?.[0]?.planType ?? null;
|
|
376
|
+
const note = hypotheticalNoteFor(provider, planType);
|
|
377
|
+
const verifiedOn = cost.rates.verifiedOn;
|
|
378
|
+
console.log(` API-equivalent pricing (${cost.model} @ rates verified ${verifiedOn}):`);
|
|
379
|
+
for (const row of cost.buckets) {
|
|
380
|
+
const tokensStr = formatTokensCompact(row.tokens);
|
|
381
|
+
const rateStr = formatRate(row.ratePerMillion);
|
|
382
|
+
console.log(
|
|
383
|
+
` ${row.label.padEnd(18)} ${formatUsd(row.costUsd).padStart(8)} (${tokensStr} × ${rateStr}/1M)`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
console.log(` ───────────────────────────────────────────`);
|
|
387
|
+
console.log(` total API cost ${formatUsd(cost.totalUsd).padStart(8)} [${note}]`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (label === 'CODEX' && totals.quotaSamples.length > 0) {
|
|
392
|
+
const first = totals.quotaSamples[0];
|
|
393
|
+
const last = totals.quotaSamples[totals.quotaSamples.length - 1];
|
|
394
|
+
console.log();
|
|
395
|
+
console.log(` Quota (plan_type=${first.planType ?? '?'}, ${totals.quotaSamples.length} samples):`);
|
|
396
|
+
// Render every window the provider exposed, by label. Pulls first/last/peak
|
|
397
|
+
// for each label across the sample series.
|
|
398
|
+
const labels = uniqueWindowLabels(totals.quotaSamples);
|
|
399
|
+
for (const lbl of labels) {
|
|
400
|
+
const firstW = findWindow(first, lbl);
|
|
401
|
+
const lastW = findWindow(last, lbl);
|
|
402
|
+
if (firstW === undefined || lastW === undefined) continue;
|
|
403
|
+
const peak = totals.quotaSamples.reduce((m, s) => {
|
|
404
|
+
const w = findWindow(s, lbl);
|
|
405
|
+
return w === undefined ? m : Math.max(m, w.usedPercent);
|
|
406
|
+
}, 0);
|
|
407
|
+
const delta = lastW.usedPercent - firstW.usedPercent;
|
|
408
|
+
const deltaStr = delta > 0 ? `+${delta.toFixed(1)} pp` : `${delta.toFixed(1)} pp`;
|
|
409
|
+
console.log(
|
|
410
|
+
` ${formatWindow(firstW.windowMinutes).padEnd(8)} ${lbl.padEnd(10)} ` +
|
|
411
|
+
`${firstW.usedPercent.toFixed(0)}% → ${lastW.usedPercent.toFixed(0)}% used ` +
|
|
412
|
+
`(peak ${peak.toFixed(0)}%, this issue moved ${deltaStr})`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
} else if (label === 'CODEX') {
|
|
416
|
+
console.log(' Quota: not captured (no rate_limits in record)');
|
|
417
|
+
} else {
|
|
418
|
+
console.log(' Quota: not exposed by Claude');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function findWindow(sample, label) {
|
|
423
|
+
if (!sample || !Array.isArray(sample.windows)) return undefined;
|
|
424
|
+
return sample.windows.find((w) => w.label === label);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function uniqueWindowLabels(samples) {
|
|
428
|
+
const labels = new Set();
|
|
429
|
+
for (const s of samples) {
|
|
430
|
+
if (!Array.isArray(s.windows)) continue;
|
|
431
|
+
for (const w of s.windows) {
|
|
432
|
+
if (typeof w.label === 'string') labels.add(w.label);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return [...labels];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function formatWindow(minutes) {
|
|
439
|
+
if (minutes >= 60 * 24) return `${(minutes / 60 / 24).toFixed(0)}d`;
|
|
440
|
+
if (minutes >= 60) return `${(minutes / 60).toFixed(0)}h`;
|
|
441
|
+
return `${minutes}m`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function spanMs(first, last) {
|
|
445
|
+
if (first === null || last === null) return 0;
|
|
446
|
+
return new Date(last).getTime() - new Date(first).getTime();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function pad(s, width) {
|
|
450
|
+
return s.length >= width ? s : ' '.repeat(width - s.length) + s;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function formatUsd(usd) {
|
|
454
|
+
if (usd === 0) return '$0.00';
|
|
455
|
+
if (usd < 0.01) return `<$0.01`;
|
|
456
|
+
if (usd >= 1000) return `$${usd.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
|
|
457
|
+
return `$${usd.toFixed(2)}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function formatTokensCompact(n) {
|
|
461
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
462
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
463
|
+
return String(n);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function formatRate(perMillionUsd) {
|
|
467
|
+
if (perMillionUsd >= 1) return `$${perMillionUsd.toFixed(2)}`;
|
|
468
|
+
if (perMillionUsd >= 0.01) return `$${perMillionUsd.toFixed(3)}`;
|
|
469
|
+
return `$${perMillionUsd.toFixed(4)}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
main().catch((err) => {
|
|
473
|
+
console.error(err);
|
|
474
|
+
process.exit(1);
|
|
475
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "llm-cost-attribution",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"llm-cost": "bin/llm-cost.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.mjs",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"codex",
|
|
23
|
+
"openai",
|
|
24
|
+
"anthropic",
|
|
25
|
+
"tokens",
|
|
26
|
+
"cost",
|
|
27
|
+
"telemetry",
|
|
28
|
+
"agentic",
|
|
29
|
+
"autonomous-developer",
|
|
30
|
+
"symphony"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/RiddimSoftware/groove.git",
|
|
39
|
+
"directory": "packages/llm-cost-attribution"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate parsed sessions into a per-issue rollup.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function emptyProviderTotals() {
|
|
6
|
+
return {
|
|
7
|
+
sessionCount: 0,
|
|
8
|
+
turnCount: 0,
|
|
9
|
+
tokens: {
|
|
10
|
+
inputUncached: 0,
|
|
11
|
+
inputCached: 0,
|
|
12
|
+
cacheCreate5m: 0,
|
|
13
|
+
cacheCreate1h: 0,
|
|
14
|
+
outputVisible: 0,
|
|
15
|
+
outputReasoning: 0,
|
|
16
|
+
},
|
|
17
|
+
tokensGrandTotal: 0,
|
|
18
|
+
models: [],
|
|
19
|
+
firstTimestamp: null,
|
|
20
|
+
lastTimestamp: null,
|
|
21
|
+
quotaSamples: [],
|
|
22
|
+
sourceFiles: [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rollupSessions(issueIdentifier, sessions) {
|
|
27
|
+
const providerTotals = {
|
|
28
|
+
claude: emptyProviderTotals(),
|
|
29
|
+
codex: emptyProviderTotals(),
|
|
30
|
+
};
|
|
31
|
+
const modelSets = { claude: new Set(), codex: new Set() };
|
|
32
|
+
|
|
33
|
+
for (const session of sessions) {
|
|
34
|
+
const totals = providerTotals[session.provider];
|
|
35
|
+
totals.sessionCount += 1;
|
|
36
|
+
totals.sourceFiles.push(session.sourceFile);
|
|
37
|
+
totals.quotaSamples.push(...session.quotaSamples);
|
|
38
|
+
|
|
39
|
+
for (const turn of session.turns) {
|
|
40
|
+
totals.turnCount += 1;
|
|
41
|
+
totals.tokens.inputUncached += turn.tokens.inputUncached;
|
|
42
|
+
totals.tokens.inputCached += turn.tokens.inputCached;
|
|
43
|
+
totals.tokens.cacheCreate5m += turn.tokens.cacheCreate5m;
|
|
44
|
+
totals.tokens.cacheCreate1h += turn.tokens.cacheCreate1h;
|
|
45
|
+
totals.tokens.outputVisible += turn.tokens.outputVisible;
|
|
46
|
+
totals.tokens.outputReasoning += turn.tokens.outputReasoning;
|
|
47
|
+
if (turn.model !== undefined && turn.model !== '') modelSets[session.provider].add(turn.model);
|
|
48
|
+
if (turn.timestamp !== '') {
|
|
49
|
+
if (totals.firstTimestamp === null || turn.timestamp < totals.firstTimestamp) {
|
|
50
|
+
totals.firstTimestamp = turn.timestamp;
|
|
51
|
+
}
|
|
52
|
+
if (totals.lastTimestamp === null || turn.timestamp > totals.lastTimestamp) {
|
|
53
|
+
totals.lastTimestamp = turn.timestamp;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const provider of ['claude', 'codex']) {
|
|
60
|
+
const t = providerTotals[provider];
|
|
61
|
+
t.tokensGrandTotal =
|
|
62
|
+
t.tokens.inputUncached + t.tokens.inputCached + t.tokens.cacheCreate5m +
|
|
63
|
+
t.tokens.cacheCreate1h + t.tokens.outputVisible + t.tokens.outputReasoning;
|
|
64
|
+
t.models = [...modelSets[provider]].sort();
|
|
65
|
+
t.quotaSamples.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
issueIdentifier,
|
|
70
|
+
providerTotals,
|
|
71
|
+
combinedTokens: providerTotals.claude.tokensGrandTotal + providerTotals.codex.tokensGrandTotal,
|
|
72
|
+
combinedTurns: providerTotals.claude.turnCount + providerTotals.codex.turnCount,
|
|
73
|
+
combinedSessions: providerTotals.claude.sessionCount + providerTotals.codex.sessionCount,
|
|
74
|
+
};
|
|
75
|
+
}
|