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/src/pricing.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model rate lookup + cost calculation.
|
|
3
|
+
*
|
|
4
|
+
* Costs are USD computed from list API rates. They are a *counterfactual*
|
|
5
|
+
* for users on flat-rate subscription plans (Claude Max, Codex Pro): the
|
|
6
|
+
* dollar number is what the same token volume would have cost on
|
|
7
|
+
* pay-as-you-go API, not the marginal cost of running it on a subscription.
|
|
8
|
+
*/
|
|
9
|
+
import { PRICING_TABLE, STALE_AFTER_DAYS } from './pricing-rates.mjs';
|
|
10
|
+
|
|
11
|
+
const RATES_BY_NAME = buildRatesMap(PRICING_TABLE);
|
|
12
|
+
|
|
13
|
+
function buildRatesMap(entries) {
|
|
14
|
+
const map = new Map();
|
|
15
|
+
for (const entry of entries) map.set(entry.model, entry);
|
|
16
|
+
return map;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Canonical name lookup. Strips Anthropic's `-YYYYMMDD` date suffix and
|
|
21
|
+
* `-latest`, and rewrites hyphenated decimals back to dotted form (the
|
|
22
|
+
* Claude transcript emits `claude-sonnet-4-6` for the model the rate
|
|
23
|
+
* table calls `claude-sonnet-4.6`).
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeModelName(model) {
|
|
26
|
+
if (typeof model !== 'string') return '';
|
|
27
|
+
let s = model.replace(/-\d{8}$/, '').replace(/-latest$/, '');
|
|
28
|
+
s = s.replace(/(\d)-(\d)/g, '$1.$2');
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Look up rates for a model name. Returns `null` if not in the table.
|
|
34
|
+
*/
|
|
35
|
+
export function ratesForModel(model) {
|
|
36
|
+
if (typeof model !== 'string' || model === '') return null;
|
|
37
|
+
return RATES_BY_NAME.get(model) ?? RATES_BY_NAME.get(normalizeModelName(model)) ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute API-equivalent cost from a TokenBuckets-shaped object.
|
|
42
|
+
*
|
|
43
|
+
* Returns:
|
|
44
|
+
* {
|
|
45
|
+
* model,
|
|
46
|
+
* rates, the PricingEntry used
|
|
47
|
+
* buckets: [ one entry per non-zero bucket, in display order
|
|
48
|
+
* { label, tokens, ratePerMillion, costUsd, note? },
|
|
49
|
+
* ...
|
|
50
|
+
* ],
|
|
51
|
+
* totalUsd, sum of buckets[i].costUsd
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* Returns `null` if no rates known for the model. Buckets with zero
|
|
55
|
+
* tokens are omitted. Buckets whose provider doesn't price them
|
|
56
|
+
* separately (e.g. Anthropic cache writes for an OpenAI model) are
|
|
57
|
+
* folded into `inputUncached` so the grand total still matches.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} model
|
|
60
|
+
* @param {object} buckets TokenBuckets shape (inputUncached, inputCached,
|
|
61
|
+
* cacheCreate5m, cacheCreate1h, outputVisible,
|
|
62
|
+
* outputReasoning).
|
|
63
|
+
*/
|
|
64
|
+
export function calculateCost(model, buckets) {
|
|
65
|
+
const rates = ratesForModel(model);
|
|
66
|
+
if (rates === null) return null;
|
|
67
|
+
|
|
68
|
+
const rows = [];
|
|
69
|
+
|
|
70
|
+
// Input — uncached. Cache writes that the provider doesn't price
|
|
71
|
+
// separately get rolled into here too.
|
|
72
|
+
let inputUncached = numOrZero(buckets.inputUncached);
|
|
73
|
+
if (rates.cacheWrite5mPerMillionUsd === null) {
|
|
74
|
+
inputUncached += numOrZero(buckets.cacheCreate5m);
|
|
75
|
+
}
|
|
76
|
+
if (rates.cacheWrite1hPerMillionUsd === null) {
|
|
77
|
+
inputUncached += numOrZero(buckets.cacheCreate1h);
|
|
78
|
+
}
|
|
79
|
+
pushIfNonZero(rows, 'input uncached', inputUncached, rates.inputPerMillionUsd);
|
|
80
|
+
|
|
81
|
+
// Input — cache read.
|
|
82
|
+
if (rates.cachedInputPerMillionUsd !== null) {
|
|
83
|
+
pushIfNonZero(rows, 'cache read', numOrZero(buckets.inputCached), rates.cachedInputPerMillionUsd);
|
|
84
|
+
} else {
|
|
85
|
+
// Provider doesn't price cache reads separately — treat them as uncached
|
|
86
|
+
// input. Add to the input-uncached row we already emitted (if any) or
|
|
87
|
+
// create one. Simpler: just emit a separate row at the input rate.
|
|
88
|
+
pushIfNonZero(rows, 'cache read', numOrZero(buckets.inputCached), rates.inputPerMillionUsd);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cache writes — only when the provider prices them separately.
|
|
92
|
+
if (rates.cacheWrite5mPerMillionUsd !== null) {
|
|
93
|
+
pushIfNonZero(rows, 'cache write 5m', numOrZero(buckets.cacheCreate5m), rates.cacheWrite5mPerMillionUsd);
|
|
94
|
+
}
|
|
95
|
+
if (rates.cacheWrite1hPerMillionUsd !== null) {
|
|
96
|
+
pushIfNonZero(rows, 'cache write 1h', numOrZero(buckets.cacheCreate1h), rates.cacheWrite1hPerMillionUsd);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Output — visible and reasoning are billed at the same rate; show them
|
|
100
|
+
// as separate rows so the science-fair viewer sees the split.
|
|
101
|
+
pushIfNonZero(rows, 'output (visible)', numOrZero(buckets.outputVisible), rates.outputPerMillionUsd);
|
|
102
|
+
pushIfNonZero(rows, 'output (reasoning)', numOrZero(buckets.outputReasoning), rates.outputPerMillionUsd);
|
|
103
|
+
|
|
104
|
+
const totalUsd = rows.reduce((s, r) => s + r.costUsd, 0);
|
|
105
|
+
|
|
106
|
+
return { model, rates, buckets: rows, totalUsd };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pushIfNonZero(rows, label, tokens, ratePerMillion) {
|
|
110
|
+
if (tokens === 0) return;
|
|
111
|
+
rows.push({
|
|
112
|
+
label,
|
|
113
|
+
tokens,
|
|
114
|
+
ratePerMillion,
|
|
115
|
+
costUsd: (tokens * ratePerMillion) / 1_000_000,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function numOrZero(v) {
|
|
120
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Days since the pricing table was last verified against the provider's
|
|
125
|
+
* pricing page. Used by the CLI to warn that rates may be stale.
|
|
126
|
+
*/
|
|
127
|
+
export function daysSincePricingVerified(asOf = new Date()) {
|
|
128
|
+
let oldest = null;
|
|
129
|
+
for (const entry of PRICING_TABLE) {
|
|
130
|
+
const verified = new Date(entry.verifiedOn).getTime();
|
|
131
|
+
if (!Number.isFinite(verified)) continue;
|
|
132
|
+
if (oldest === null || verified < oldest) oldest = verified;
|
|
133
|
+
}
|
|
134
|
+
if (oldest === null) return Infinity;
|
|
135
|
+
return Math.floor((asOf.getTime() - oldest) / (1000 * 60 * 60 * 24));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isPricingStale(asOf = new Date()) {
|
|
139
|
+
return daysSincePricingVerified(asOf) > STALE_AFTER_DAYS;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Plan-type-aware hypothetical note for the printer.
|
|
144
|
+
*/
|
|
145
|
+
export function hypotheticalNoteFor(provider, planType) {
|
|
146
|
+
if (provider === 'codex' && typeof planType === 'string' && planType !== '') {
|
|
147
|
+
return `hypothetical — your Codex ${capitalize(planType)} plan covers this`;
|
|
148
|
+
}
|
|
149
|
+
if (provider === 'claude') {
|
|
150
|
+
return 'hypothetical — Anthropic API rate, your Max plan billing may differ';
|
|
151
|
+
}
|
|
152
|
+
return 'hypothetical — API list rate, your plan billing may differ';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function capitalize(s) {
|
|
156
|
+
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
|
|
157
|
+
}
|
package/src/quota.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec §5.2.3 quota helpers.
|
|
3
|
+
*
|
|
4
|
+
* A `quota` object on a usage record is a point-in-time sample of the
|
|
5
|
+
* provider's rate-limit state at the END of the turn. The spec shape:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "planType": "pro",
|
|
9
|
+
* "windows": [
|
|
10
|
+
* { "label": "primary", "windowMinutes": 300, "usedPercent": 64.0, "resetsAt": 1779863673 },
|
|
11
|
+
* { "label": "secondary", "windowMinutes": 10080, "usedPercent": 57.0, "resetsAt": 1780187884 }
|
|
12
|
+
* ]
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Reserved labels are `primary` (short window, e.g. 5h for Codex Pro) and
|
|
16
|
+
* `secondary` (long window, e.g. 7d for Codex Pro). Implementations MAY add
|
|
17
|
+
* additional labels; readers MUST treat unknown labels as opaque.
|
|
18
|
+
*
|
|
19
|
+
* `QuotaSample` (the in-memory shape used inside this package) is the same
|
|
20
|
+
* structure plus a `provider` tag and a `timestamp`:
|
|
21
|
+
*
|
|
22
|
+
* {
|
|
23
|
+
* "provider": "codex",
|
|
24
|
+
* "timestamp": "2026-05-18T21:50:34Z",
|
|
25
|
+
* "planType": "pro",
|
|
26
|
+
* "windows": [...same as above...]
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Strip the in-memory `QuotaSample` wrapping down to the spec's bare
|
|
32
|
+
* `quota` object shape. Returns `null` if the sample has no windows.
|
|
33
|
+
*/
|
|
34
|
+
export function quotaSampleToSpecObject(sample) {
|
|
35
|
+
if (!sample || !Array.isArray(sample.windows) || sample.windows.length === 0) return null;
|
|
36
|
+
return {
|
|
37
|
+
planType: sample.planType ?? '',
|
|
38
|
+
windows: sample.windows.map((w) => {
|
|
39
|
+
const out = {
|
|
40
|
+
label: w.label,
|
|
41
|
+
windowMinutes: w.windowMinutes,
|
|
42
|
+
usedPercent: w.usedPercent,
|
|
43
|
+
};
|
|
44
|
+
if (typeof w.resetsAt === 'number') out.resetsAt = w.resetsAt;
|
|
45
|
+
return out;
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find the window with the given label inside a QuotaSample. Returns
|
|
52
|
+
* `undefined` if absent.
|
|
53
|
+
*/
|
|
54
|
+
export function findWindow(sample, label) {
|
|
55
|
+
if (!sample || !Array.isArray(sample.windows)) return undefined;
|
|
56
|
+
return sample.windows.find((w) => w.label === label);
|
|
57
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert ParsedSession objects (from src/transcripts/*.mjs) into spec-
|
|
3
|
+
* compliant usage.jsonl records.
|
|
4
|
+
*
|
|
5
|
+
* Emits every REQUIRED field in spec §5.1 plus the OPTIONAL provider-
|
|
6
|
+
* specific fields the transcript surface gives us:
|
|
7
|
+
* §5.2.1 input-token breakdown (uncached / cached / cache-write tiers)
|
|
8
|
+
* §5.2.2 output-token breakdown (visible / reasoning)
|
|
9
|
+
* §5.2.3 quota object (built from Codex rate_limits samples, when present)
|
|
10
|
+
* workspacePath
|
|
11
|
+
*
|
|
12
|
+
* Spec-optional fields the CLI transcript doesn't carry (`estimate`,
|
|
13
|
+
* `pullRequest`, `effort`, `exitReason`, ...) are left out.
|
|
14
|
+
*
|
|
15
|
+
* `botRole` is hardcoded to `developer` per spec §5.1: "Implementations
|
|
16
|
+
* that do not distinguish a reviewer role MUST emit `developer`."
|
|
17
|
+
*/
|
|
18
|
+
import { quotaSampleToSpecObject } from './quota.mjs';
|
|
19
|
+
import { SCHEMA_VERSION } from './usage-jsonl.mjs';
|
|
20
|
+
|
|
21
|
+
export { quotaSampleToSpecObject } from './quota.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('./types').ParsedSession} session
|
|
25
|
+
* @param {string} issueIdentifier
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {string} [opts.recordedAt] Override the recordedAt timestamp (default: now).
|
|
28
|
+
*/
|
|
29
|
+
export function sessionToUsageRecords(session, issueIdentifier, opts = {}) {
|
|
30
|
+
const recordedAt = opts.recordedAt ?? new Date().toISOString();
|
|
31
|
+
const out = [];
|
|
32
|
+
|
|
33
|
+
// Sort turns by index so the per-record `turn` ordinal is monotonic.
|
|
34
|
+
const turns = [...session.turns].sort((a, b) => a.turnIdx - b.turnIdx);
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < turns.length; i++) {
|
|
37
|
+
const turn = turns[i];
|
|
38
|
+
const next = turns[i + 1];
|
|
39
|
+
|
|
40
|
+
// The CLI transcript only records ONE timestamp per turn. We use it as
|
|
41
|
+
// both startedAt and as the endedAt of the previous turn. For the LAST
|
|
42
|
+
// turn we have no successor, so endedAt == startedAt.
|
|
43
|
+
const startedAt = turn.timestamp || recordedAt;
|
|
44
|
+
const endedAt = next?.timestamp ?? startedAt;
|
|
45
|
+
|
|
46
|
+
// Spec totals: claude sums every input bucket + visible output; codex
|
|
47
|
+
// sums uncached + cached + visible + reasoning output. Both end up as
|
|
48
|
+
// (inputTokens + outputTokens) per spec §5.3.
|
|
49
|
+
const inputTokens = turn.tokens.inputUncached + turn.tokens.inputCached +
|
|
50
|
+
turn.tokens.cacheCreate5m + turn.tokens.cacheCreate1h;
|
|
51
|
+
const outputTokens = turn.tokens.outputVisible + turn.tokens.outputReasoning;
|
|
52
|
+
const totalTokens = inputTokens + outputTokens;
|
|
53
|
+
|
|
54
|
+
const record = {
|
|
55
|
+
schemaVersion: SCHEMA_VERSION,
|
|
56
|
+
recordedAt,
|
|
57
|
+
runID: session.sessionId,
|
|
58
|
+
turn: i + 1, // spec §5.1: 1-based, strictly increasing within runID
|
|
59
|
+
issueIdentifier,
|
|
60
|
+
provider: session.provider,
|
|
61
|
+
model: turn.model ?? '',
|
|
62
|
+
botRole: 'developer',
|
|
63
|
+
inputTokens,
|
|
64
|
+
outputTokens,
|
|
65
|
+
totalTokens,
|
|
66
|
+
usageSource: 'provider_reported',
|
|
67
|
+
startedAt,
|
|
68
|
+
endedAt,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// OPTIONAL §5.2 — populate when the transcript gives us the value.
|
|
72
|
+
if (session.cwd !== '') {
|
|
73
|
+
record.workspacePath = session.cwd;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// §5.2.1 input-token breakdown — both Claude and Codex report uncached + cached;
|
|
77
|
+
// Claude additionally reports cache-creation tokens split by ephemeral tier;
|
|
78
|
+
// Codex bundles writes into the cached total.
|
|
79
|
+
record.inputUncachedTokens = turn.tokens.inputUncached;
|
|
80
|
+
record.inputCachedReadTokens = turn.tokens.inputCached;
|
|
81
|
+
if (session.provider === 'claude') {
|
|
82
|
+
const writeTotal = turn.tokens.cacheCreate5m + turn.tokens.cacheCreate1h;
|
|
83
|
+
if (writeTotal > 0) {
|
|
84
|
+
record.inputCacheWriteTokens = writeTotal;
|
|
85
|
+
if (turn.tokens.cacheCreate5m > 0) record.inputCacheWriteEphemeral5mTokens = turn.tokens.cacheCreate5m;
|
|
86
|
+
if (turn.tokens.cacheCreate1h > 0) record.inputCacheWriteEphemeral1hTokens = turn.tokens.cacheCreate1h;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// §5.2.2 output-token breakdown. Codex always reports
|
|
91
|
+
// reasoning_output_tokens (even when zero), so we emit it unconditionally
|
|
92
|
+
// for Codex — its presence is itself a provider signal.
|
|
93
|
+
record.outputVisibleTokens = turn.tokens.outputVisible;
|
|
94
|
+
if (session.provider === 'codex') {
|
|
95
|
+
record.outputReasoningTokens = turn.tokens.outputReasoning;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// §5.2.3 quota — Codex emits a rate_limits payload per token_count event.
|
|
99
|
+
// The per-turn `quota` is the point-in-time sample at the end of the turn;
|
|
100
|
+
// we use the last sample whose timestamp is ≤ this turn's endedAt.
|
|
101
|
+
const sample = lastQuotaSampleAtOrBefore(session.quotaSamples, endedAt);
|
|
102
|
+
if (sample !== null) {
|
|
103
|
+
const quota = quotaSampleToSpecObject(sample);
|
|
104
|
+
if (quota !== null) record.quota = quota;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
out.push(record);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function lastQuotaSampleAtOrBefore(samples, ts) {
|
|
114
|
+
if (samples.length === 0) return null;
|
|
115
|
+
let best = null;
|
|
116
|
+
for (const s of samples) {
|
|
117
|
+
if (s.timestamp <= ts && (best === null || s.timestamp > best.timestamp)) best = s;
|
|
118
|
+
}
|
|
119
|
+
return best ?? samples[0];
|
|
120
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Claude Code session JSONL files.
|
|
3
|
+
*
|
|
4
|
+
* Claude stores each session as one or more JSONL files under
|
|
5
|
+
* `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`, plus nested
|
|
6
|
+
* subagent files under `~/.claude/projects/<encoded-cwd>/<sessionId>/subagents/*.jsonl`.
|
|
7
|
+
*
|
|
8
|
+
* Each assistant turn carries a `message.usage` object with provider-reported
|
|
9
|
+
* token counts. This is the same accounting Anthropic bills against.
|
|
10
|
+
*/
|
|
11
|
+
import { readdir } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { numericOrZero, pathExists, readJsonl } from '../util.mjs';
|
|
14
|
+
|
|
15
|
+
export async function findClaudeProjectDirs(claudeRootDir, matchesIssue) {
|
|
16
|
+
if (!(await pathExists(claudeRootDir))) return [];
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = await readdir(claudeRootDir, { withFileTypes: true });
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (entry.isDirectory() && matchesIssue(entry.name)) {
|
|
26
|
+
out.push(join(claudeRootDir, entry.name));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function listJsonlsRecursively(dir) {
|
|
33
|
+
if (!(await pathExists(dir))) return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
async function walk(d) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const full = join(d, e.name);
|
|
44
|
+
if (e.isDirectory()) await walk(full);
|
|
45
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await walk(dir);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function parseClaudeSession(file) {
|
|
53
|
+
let sessionId;
|
|
54
|
+
let cwd;
|
|
55
|
+
const turns = [];
|
|
56
|
+
let turnIdx = 0;
|
|
57
|
+
|
|
58
|
+
for await (const rec of readJsonl(file)) {
|
|
59
|
+
if (typeof rec.sessionId === 'string' && sessionId === undefined) sessionId = rec.sessionId;
|
|
60
|
+
if (typeof rec.cwd === 'string' && cwd === undefined) cwd = rec.cwd;
|
|
61
|
+
|
|
62
|
+
const msg = rec.message;
|
|
63
|
+
const usage = msg?.usage;
|
|
64
|
+
if (!usage) continue;
|
|
65
|
+
const cacheCreation = usage.cache_creation;
|
|
66
|
+
const serverToolUse = usage.server_tool_use;
|
|
67
|
+
const ts = typeof rec.timestamp === 'string' ? rec.timestamp : '';
|
|
68
|
+
const model = typeof msg.model === 'string' ? msg.model : undefined;
|
|
69
|
+
turns.push({
|
|
70
|
+
provider: 'claude',
|
|
71
|
+
sessionId: sessionId ?? '',
|
|
72
|
+
turnIdx,
|
|
73
|
+
timestamp: ts,
|
|
74
|
+
model,
|
|
75
|
+
cwd: cwd ?? '',
|
|
76
|
+
tokens: {
|
|
77
|
+
inputUncached: numericOrZero(usage.input_tokens),
|
|
78
|
+
inputCached: numericOrZero(usage.cache_read_input_tokens),
|
|
79
|
+
cacheCreate5m: numericOrZero(cacheCreation?.ephemeral_5m_input_tokens),
|
|
80
|
+
cacheCreate1h: numericOrZero(cacheCreation?.ephemeral_1h_input_tokens),
|
|
81
|
+
outputVisible: numericOrZero(usage.output_tokens),
|
|
82
|
+
outputReasoning: 0,
|
|
83
|
+
},
|
|
84
|
+
webSearchRequests: numericOrZero(serverToolUse?.web_search_requests),
|
|
85
|
+
webFetchRequests: numericOrZero(serverToolUse?.web_fetch_requests),
|
|
86
|
+
});
|
|
87
|
+
turnIdx += 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (turns.length === 0 && sessionId === undefined) return null;
|
|
91
|
+
return {
|
|
92
|
+
provider: 'claude',
|
|
93
|
+
sessionId: sessionId ?? '',
|
|
94
|
+
cwd: cwd ?? '',
|
|
95
|
+
sourceFile: file,
|
|
96
|
+
turns,
|
|
97
|
+
quotaSamples: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Codex CLI rollout JSONL files.
|
|
3
|
+
*
|
|
4
|
+
* Codex stores each session as a single JSONL under
|
|
5
|
+
* `~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`. The first
|
|
6
|
+
* record is `session_meta` with `payload.cwd`; subsequent `event_msg` records
|
|
7
|
+
* of type `token_count` carry both:
|
|
8
|
+
* - `payload.info.total_token_usage` (cumulative across the session)
|
|
9
|
+
* - `payload.rate_limits` (per-window quota % at this moment)
|
|
10
|
+
*
|
|
11
|
+
* We delta the cumulative usage to produce per-turn token counts, and
|
|
12
|
+
* collect every rate-limits sample we see.
|
|
13
|
+
*/
|
|
14
|
+
import { readdir } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { numericOrZero, pathExists, readJsonl } from '../util.mjs';
|
|
17
|
+
|
|
18
|
+
const ZERO_CUMULATIVE = { total: 0, input: 0, cached: 0, output: 0, reasoning: 0 };
|
|
19
|
+
|
|
20
|
+
export async function listCodexRollouts(codexRootDir) {
|
|
21
|
+
if (!(await pathExists(codexRootDir))) return [];
|
|
22
|
+
const out = [];
|
|
23
|
+
async function walk(d) {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const full = join(d, e.name);
|
|
32
|
+
if (e.isDirectory()) await walk(full);
|
|
33
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await walk(codexRootDir);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function parseCodexSession(file) {
|
|
41
|
+
let sessionId;
|
|
42
|
+
let cwd;
|
|
43
|
+
let model;
|
|
44
|
+
let prev = ZERO_CUMULATIVE;
|
|
45
|
+
let turnIdx = 0;
|
|
46
|
+
const turns = [];
|
|
47
|
+
const quotaSamples = [];
|
|
48
|
+
|
|
49
|
+
for await (const rec of readJsonl(file)) {
|
|
50
|
+
const type = rec.type;
|
|
51
|
+
const payload = rec.payload;
|
|
52
|
+
const ts = typeof rec.timestamp === 'string' ? rec.timestamp : '';
|
|
53
|
+
|
|
54
|
+
if (type === 'session_meta' && payload !== undefined) {
|
|
55
|
+
if (typeof payload.id === 'string') sessionId = payload.id;
|
|
56
|
+
if (typeof payload.cwd === 'string') cwd = payload.cwd;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (type === 'turn_context' && payload !== undefined && typeof payload.model === 'string') {
|
|
60
|
+
model = payload.model;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (type !== 'event_msg' || payload === undefined || payload.type !== 'token_count') continue;
|
|
64
|
+
|
|
65
|
+
const totalUsage = payload.info?.total_token_usage;
|
|
66
|
+
if (totalUsage !== undefined) {
|
|
67
|
+
const current = {
|
|
68
|
+
total: numericOrZero(totalUsage.total_tokens),
|
|
69
|
+
input: numericOrZero(totalUsage.input_tokens),
|
|
70
|
+
cached: numericOrZero(totalUsage.cached_input_tokens),
|
|
71
|
+
output: numericOrZero(totalUsage.output_tokens),
|
|
72
|
+
reasoning: numericOrZero(totalUsage.reasoning_output_tokens),
|
|
73
|
+
};
|
|
74
|
+
const delta = {
|
|
75
|
+
total: current.total - prev.total,
|
|
76
|
+
input: current.input - prev.input,
|
|
77
|
+
cached: current.cached - prev.cached,
|
|
78
|
+
output: current.output - prev.output,
|
|
79
|
+
reasoning: current.reasoning - prev.reasoning,
|
|
80
|
+
};
|
|
81
|
+
const allNonNegative =
|
|
82
|
+
delta.input >= 0 && delta.cached >= 0 && delta.output >= 0 && delta.reasoning >= 0;
|
|
83
|
+
if (delta.total > 0 && allNonNegative) {
|
|
84
|
+
const inputUncached = Math.max(0, delta.input - delta.cached);
|
|
85
|
+
const outputVisible = Math.max(0, delta.output - delta.reasoning);
|
|
86
|
+
turns.push({
|
|
87
|
+
provider: 'codex',
|
|
88
|
+
sessionId: sessionId ?? '',
|
|
89
|
+
turnIdx,
|
|
90
|
+
timestamp: ts,
|
|
91
|
+
model,
|
|
92
|
+
cwd: cwd ?? '',
|
|
93
|
+
tokens: {
|
|
94
|
+
inputUncached,
|
|
95
|
+
inputCached: delta.cached,
|
|
96
|
+
cacheCreate5m: 0,
|
|
97
|
+
cacheCreate1h: 0,
|
|
98
|
+
outputVisible,
|
|
99
|
+
outputReasoning: delta.reasoning,
|
|
100
|
+
},
|
|
101
|
+
webSearchRequests: 0,
|
|
102
|
+
webFetchRequests: 0,
|
|
103
|
+
});
|
|
104
|
+
turnIdx += 1;
|
|
105
|
+
prev = current;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rateLimits = payload.rate_limits;
|
|
110
|
+
if (rateLimits !== undefined && rateLimits !== null) {
|
|
111
|
+
// Build the spec §5.2.3 `quota` shape directly so the same structure
|
|
112
|
+
// round-trips through usage.jsonl without lossy reshaping.
|
|
113
|
+
const windows = [];
|
|
114
|
+
for (const [label, src] of [['primary', rateLimits.primary], ['secondary', rateLimits.secondary]]) {
|
|
115
|
+
if (!src) continue;
|
|
116
|
+
if (typeof src.window_minutes !== 'number' || src.window_minutes <= 0) continue;
|
|
117
|
+
const w = {
|
|
118
|
+
label,
|
|
119
|
+
windowMinutes: src.window_minutes,
|
|
120
|
+
usedPercent: numericOrZero(src.used_percent),
|
|
121
|
+
};
|
|
122
|
+
if (typeof src.resets_at === 'number') w.resetsAt = src.resets_at;
|
|
123
|
+
windows.push(w);
|
|
124
|
+
}
|
|
125
|
+
if (windows.length > 0) {
|
|
126
|
+
quotaSamples.push({
|
|
127
|
+
provider: 'codex',
|
|
128
|
+
timestamp: ts,
|
|
129
|
+
planType: typeof rateLimits.plan_type === 'string' ? rateLimits.plan_type : null,
|
|
130
|
+
windows,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (turns.length === 0 && quotaSamples.length === 0 && sessionId === undefined) return null;
|
|
137
|
+
return {
|
|
138
|
+
provider: 'codex',
|
|
139
|
+
sessionId: sessionId ?? '',
|
|
140
|
+
cwd: cwd ?? '',
|
|
141
|
+
sourceFile: file,
|
|
142
|
+
turns,
|
|
143
|
+
quotaSamples,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate spec-compliant usage records into the same shape `rollupSessions`
|
|
3
|
+
* produces from raw transcripts. Keeps the CLI output identical regardless
|
|
4
|
+
* of which source the cost data came from.
|
|
5
|
+
*
|
|
6
|
+
* As of spec v1 with the §5.2.{1,2,3} additions, every signal the
|
|
7
|
+
* transcript carried is preserved on backfill:
|
|
8
|
+
* - input/output token breakdowns
|
|
9
|
+
* - Claude cache-tier split (ephemeral 5m vs 1h)
|
|
10
|
+
* - Codex reasoning-vs-visible output split
|
|
11
|
+
* - Codex per-window quota samples (`quota` object per record)
|
|
12
|
+
*
|
|
13
|
+
* When reading records produced by a writer that did NOT emit the OPTIONAL
|
|
14
|
+
* breakdown fields, this aggregator falls back to crediting the entire
|
|
15
|
+
* `inputTokens` to `inputUncached` and `outputTokens` to `outputVisible`.
|
|
16
|
+
* Grand totals are correct either way.
|
|
17
|
+
*/
|
|
18
|
+
import { emptyProviderTotals } from './aggregator.mjs';
|
|
19
|
+
|
|
20
|
+
function numOr0(v) {
|
|
21
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} issueIdentifier
|
|
26
|
+
* @param {Iterable<object>} records
|
|
27
|
+
*/
|
|
28
|
+
export function rollupUsageRecords(issueIdentifier, records) {
|
|
29
|
+
const providerTotals = {
|
|
30
|
+
claude: emptyProviderTotals(),
|
|
31
|
+
codex: emptyProviderTotals(),
|
|
32
|
+
};
|
|
33
|
+
const modelSets = { claude: new Set(), codex: new Set() };
|
|
34
|
+
const sessionsSeen = { claude: new Set(), codex: new Set() };
|
|
35
|
+
|
|
36
|
+
for (const rec of records) {
|
|
37
|
+
if (rec.issueIdentifier !== issueIdentifier) continue;
|
|
38
|
+
if (rec.provider !== 'claude' && rec.provider !== 'codex') continue;
|
|
39
|
+
if (rec.usageSource === 'unavailable') continue;
|
|
40
|
+
|
|
41
|
+
const totals = providerTotals[rec.provider];
|
|
42
|
+
totals.turnCount += 1;
|
|
43
|
+
|
|
44
|
+
// Prefer the §5.2.1 breakdown fields when present; fall back to the
|
|
45
|
+
// REQUIRED inputTokens total when not.
|
|
46
|
+
const hasInputBreakdown =
|
|
47
|
+
typeof rec.inputUncachedTokens === 'number' ||
|
|
48
|
+
typeof rec.inputCachedReadTokens === 'number' ||
|
|
49
|
+
typeof rec.inputCacheWriteTokens === 'number';
|
|
50
|
+
if (hasInputBreakdown) {
|
|
51
|
+
totals.tokens.inputUncached += numOr0(rec.inputUncachedTokens);
|
|
52
|
+
totals.tokens.inputCached += numOr0(rec.inputCachedReadTokens);
|
|
53
|
+
totals.tokens.cacheCreate5m += numOr0(rec.inputCacheWriteEphemeral5mTokens);
|
|
54
|
+
totals.tokens.cacheCreate1h += numOr0(rec.inputCacheWriteEphemeral1hTokens);
|
|
55
|
+
// If a writer emitted inputCacheWriteTokens but didn't split by tier
|
|
56
|
+
// (a non-Anthropic provider with a single cache tier, say), credit
|
|
57
|
+
// the unattributed write tokens to inputUncached so the grand total
|
|
58
|
+
// still matches.
|
|
59
|
+
const splitTotal = numOr0(rec.inputCacheWriteEphemeral5mTokens) + numOr0(rec.inputCacheWriteEphemeral1hTokens);
|
|
60
|
+
const writeTotal = numOr0(rec.inputCacheWriteTokens);
|
|
61
|
+
if (writeTotal > splitTotal) totals.tokens.inputUncached += (writeTotal - splitTotal);
|
|
62
|
+
} else {
|
|
63
|
+
totals.tokens.inputUncached += rec.inputTokens ?? 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// §5.2.2 output breakdown.
|
|
67
|
+
const hasOutputBreakdown =
|
|
68
|
+
typeof rec.outputVisibleTokens === 'number' || typeof rec.outputReasoningTokens === 'number';
|
|
69
|
+
if (hasOutputBreakdown) {
|
|
70
|
+
totals.tokens.outputVisible += numOr0(rec.outputVisibleTokens);
|
|
71
|
+
totals.tokens.outputReasoning += numOr0(rec.outputReasoningTokens);
|
|
72
|
+
} else {
|
|
73
|
+
totals.tokens.outputVisible += rec.outputTokens ?? 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// §5.2.3 quota — propagate the full per-turn sample if present.
|
|
77
|
+
if (rec.quota !== undefined && rec.quota !== null && typeof rec.quota === 'object' && Array.isArray(rec.quota.windows)) {
|
|
78
|
+
totals.quotaSamples.push({
|
|
79
|
+
provider: rec.provider,
|
|
80
|
+
timestamp: typeof rec.endedAt === 'string' ? rec.endedAt : '',
|
|
81
|
+
planType: typeof rec.quota.planType === 'string' ? rec.quota.planType : null,
|
|
82
|
+
windows: rec.quota.windows,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof rec.model === 'string' && rec.model !== '') modelSets[rec.provider].add(rec.model);
|
|
87
|
+
if (typeof rec.runID === 'string' && rec.runID !== '') sessionsSeen[rec.provider].add(rec.runID);
|
|
88
|
+
const startedAt = typeof rec.startedAt === 'string' ? rec.startedAt : null;
|
|
89
|
+
const endedAt = typeof rec.endedAt === 'string' ? rec.endedAt : null;
|
|
90
|
+
if (startedAt !== null && (totals.firstTimestamp === null || startedAt < totals.firstTimestamp)) {
|
|
91
|
+
totals.firstTimestamp = startedAt;
|
|
92
|
+
}
|
|
93
|
+
if (endedAt !== null && (totals.lastTimestamp === null || endedAt > totals.lastTimestamp)) {
|
|
94
|
+
totals.lastTimestamp = endedAt;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const provider of ['claude', 'codex']) {
|
|
99
|
+
const t = providerTotals[provider];
|
|
100
|
+
t.sessionCount = sessionsSeen[provider].size;
|
|
101
|
+
t.tokensGrandTotal =
|
|
102
|
+
t.tokens.inputUncached + t.tokens.inputCached + t.tokens.cacheCreate5m +
|
|
103
|
+
t.tokens.cacheCreate1h + t.tokens.outputVisible + t.tokens.outputReasoning;
|
|
104
|
+
t.models = [...modelSets[provider]].sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
issueIdentifier,
|
|
109
|
+
providerTotals,
|
|
110
|
+
combinedTokens: providerTotals.claude.tokensGrandTotal + providerTotals.codex.tokensGrandTotal,
|
|
111
|
+
combinedTurns: providerTotals.claude.turnCount + providerTotals.codex.turnCount,
|
|
112
|
+
combinedSessions: providerTotals.claude.sessionCount + providerTotals.codex.sessionCount,
|
|
113
|
+
};
|
|
114
|
+
}
|