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/src/index.mjs ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Library API for `llm-cost-attribution`.
3
+ *
4
+ * Use this to compute per-issue token/turn/quota rollups from your own
5
+ * code. For a ready-to-run command, see the `llm-cost` binary
6
+ * (bin/llm-cost.mjs).
7
+ */
8
+ import { homedir } from 'node:os';
9
+ import { basename, join } from 'node:path';
10
+ import { rollupSessions } from './aggregator.mjs';
11
+ import { DEFAULT_CWD_PATTERN, issueFromClaudeProjectDirName, issueFromCwd } from './issue-pattern.mjs';
12
+ import { findClaudeProjectDirs, listJsonlsRecursively, parseClaudeSession } from './transcripts/claude.mjs';
13
+ import { listCodexRollouts, parseCodexSession } from './transcripts/codex.mjs';
14
+ import { sessionToUsageRecords } from './transcript-to-usage.mjs';
15
+ import { appendUsageRecords, readUsageRecords, validateUsageRecord } from './usage-jsonl.mjs';
16
+ import { rollupUsageRecords } from './usage-aggregator.mjs';
17
+
18
+ export { DEFAULT_CWD_PATTERN, issueFromCwd, issueFromClaudeProjectDirName } from './issue-pattern.mjs';
19
+ export { rollupSessions } from './aggregator.mjs';
20
+ export { rollupUsageRecords } from './usage-aggregator.mjs';
21
+ export { sessionToUsageRecords } from './transcript-to-usage.mjs';
22
+ export {
23
+ SCHEMA_VERSION,
24
+ findUsageFiles,
25
+ readUsageRecords,
26
+ appendUsageRecords,
27
+ validateUsageRecord,
28
+ } from './usage-jsonl.mjs';
29
+ export {
30
+ computeMultiIssueRollup,
31
+ expandAllIssueArgs,
32
+ expandIssueArg,
33
+ } from './multi-issue.mjs';
34
+ export {
35
+ PRICING_TABLE,
36
+ STALE_AFTER_DAYS,
37
+ } from './pricing-rates.mjs';
38
+ export {
39
+ calculateCost,
40
+ daysSincePricingVerified,
41
+ hypotheticalNoteFor,
42
+ isPricingStale,
43
+ normalizeModelName,
44
+ ratesForModel,
45
+ } from './pricing.mjs';
46
+
47
+ /**
48
+ * Read every Claude session whose encoded project directory name matches the
49
+ * given issue identifier, plus every Codex rollout whose `session_meta.cwd`
50
+ * matches it, and aggregate them into a single per-issue rollup.
51
+ *
52
+ * @param {string} issueIdentifier e.g. "EPAC-1940"
53
+ * @param {object} [options]
54
+ * @param {RegExp} [options.cwdPattern] Default matches Symphony / Autopilot convention.
55
+ * @param {string} [options.claudeProjectsDir] Override `~/.claude/projects`.
56
+ * @param {string} [options.codexSessionsDir] Override `~/.codex/sessions`.
57
+ */
58
+ export async function computeIssueCost(issueIdentifier, options = {}) {
59
+ const cwdPattern = options.cwdPattern ?? DEFAULT_CWD_PATTERN;
60
+ const claudeRootDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects');
61
+ const codexRootDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions');
62
+
63
+ const sessions = [];
64
+
65
+ // Claude: directory-name match.
66
+ const matchingProjectDirs = await findClaudeProjectDirs(
67
+ claudeRootDir,
68
+ (encoded) => issueFromClaudeProjectDirName(encoded, cwdPattern) === issueIdentifier,
69
+ );
70
+ for (const dir of matchingProjectDirs) {
71
+ for (const file of await listJsonlsRecursively(dir)) {
72
+ const session = await parseClaudeSession(file);
73
+ if (session !== null) sessions.push(session);
74
+ }
75
+ }
76
+
77
+ // Codex: session_meta.cwd match, scanned across all rollouts.
78
+ for (const file of await listCodexRollouts(codexRootDir)) {
79
+ const session = await parseCodexSession(file);
80
+ if (session === null) continue;
81
+ if (issueFromCwd(session.cwd, cwdPattern) === issueIdentifier) sessions.push(session);
82
+ }
83
+
84
+ return rollupSessions(issueIdentifier, sessions);
85
+ }
86
+
87
+ /**
88
+ * Compute token/turn cost for all sessions run from a specific worktree
89
+ * directory, regardless of any issue identifier or Symphony convention.
90
+ * Works with any directory a user ran `claude` or `codex` from — no Linear
91
+ * issue or symphonyd required.
92
+ *
93
+ * @param {string} worktreePath Absolute path to the worktree directory.
94
+ * @param {object} [options]
95
+ * @param {string} [options.claudeProjectsDir] Override `~/.claude/projects`.
96
+ * @param {string} [options.codexSessionsDir] Override `~/.codex/sessions`.
97
+ */
98
+ export async function computeWorktreeCost(worktreePath, options = {}) {
99
+ const claudeRootDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects');
100
+ const codexRootDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions');
101
+
102
+ const sessions = [];
103
+
104
+ // Claude: the project directory name is the absolute cwd with every `/` and
105
+ // `.` replaced by `-`. Look it up directly — no regex needed.
106
+ const encodedPath = worktreePath.replace(/[/.]/g, '-');
107
+ const claudeProjectDir = join(claudeRootDir, encodedPath);
108
+ for (const file of await listJsonlsRecursively(claudeProjectDir)) {
109
+ const session = await parseClaudeSession(file);
110
+ if (session !== null) sessions.push(session);
111
+ }
112
+
113
+ // Codex: scan all rollouts, keep those whose session_meta.cwd matches exactly.
114
+ for (const file of await listCodexRollouts(codexRootDir)) {
115
+ const session = await parseCodexSession(file);
116
+ if (session === null) continue;
117
+ if (session.cwd === worktreePath) sessions.push(session);
118
+ }
119
+
120
+ return rollupSessions(basename(worktreePath), sessions);
121
+ }
122
+
123
+ /**
124
+ * Same shape as `computeIssueCost`, but sources data from a usage.jsonl file
125
+ * (or a directory of `usage*.jsonl` files) instead of the CLI transcripts.
126
+ * Use this after backfilling so you can safely delete `~/.claude/projects`
127
+ * and `~/.codex/sessions` and still query historical cost.
128
+ *
129
+ * Records with `usageSource === "unavailable"` are skipped.
130
+ *
131
+ * @param {string} issueIdentifier
132
+ * @param {string} usageSource A .jsonl file or a directory of `usage*.jsonl` files.
133
+ */
134
+ export async function computeIssueCostFromUsage(issueIdentifier, usageSource) {
135
+ const records = [];
136
+ for await (const rec of readUsageRecords(usageSource)) {
137
+ if (validateUsageRecord(rec) !== null) continue;
138
+ records.push(rec);
139
+ }
140
+ return rollupUsageRecords(issueIdentifier, records);
141
+ }
142
+
143
+ /**
144
+ * Walk every Claude session + every Codex rollout, derive spec-compliant
145
+ * usage.jsonl records for each, and append them to a single output file.
146
+ *
147
+ * Sessions whose cwd doesn't match the configured pattern are skipped (they
148
+ * aren't attributable to any issue this tool understands).
149
+ *
150
+ * After backfilling, the operator can safely delete the source transcripts
151
+ * — the usage.jsonl file carries every field needed to reproduce the cost
152
+ * rollups via `computeIssueCostFromUsage`. The fidelity tradeoff is that
153
+ * usage.jsonl drops the cache-tier split, reasoning-vs-visible split, and
154
+ * Codex quota samples — see the package README for the full list.
155
+ *
156
+ * @param {object} options
157
+ * @param {string} options.outFile Destination .jsonl path. Created if missing; appended if present.
158
+ * @param {RegExp} [options.cwdPattern]
159
+ * @param {string} [options.claudeProjectsDir]
160
+ * @param {string} [options.codexSessionsDir]
161
+ * @param {(progress: { phase: string, file?: string, processed: number, total: number, recordsWritten: number }) => void} [options.onProgress]
162
+ * @returns {Promise<{ recordsWritten: number, sessionsProcessed: number, sessionsSkipped: number }>}
163
+ */
164
+ export async function backfillUsageFromTranscripts(options) {
165
+ const outFile = options.outFile;
166
+ if (typeof outFile !== 'string' || outFile === '') {
167
+ throw new TypeError('backfillUsageFromTranscripts: options.outFile is required');
168
+ }
169
+ const cwdPattern = options.cwdPattern ?? DEFAULT_CWD_PATTERN;
170
+ const claudeRootDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects');
171
+ const codexRootDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions');
172
+ const onProgress = options.onProgress ?? (() => undefined);
173
+
174
+ let recordsWritten = 0;
175
+ let sessionsProcessed = 0;
176
+ let sessionsSkipped = 0;
177
+
178
+ // Phase 1: walk Claude project dirs and emit records for matching sessions.
179
+ const claudeDirs = await findClaudeProjectDirs(claudeRootDir, (encoded) => issueFromClaudeProjectDirName(encoded, cwdPattern) !== null);
180
+ for (let i = 0; i < claudeDirs.length; i++) {
181
+ const dir = claudeDirs[i];
182
+ const encoded = dir.split('/').pop() ?? '';
183
+ const issueIdentifier = issueFromClaudeProjectDirName(encoded, cwdPattern);
184
+ if (issueIdentifier === null) continue;
185
+ for (const file of await listJsonlsRecursively(dir)) {
186
+ const session = await parseClaudeSession(file);
187
+ if (session === null) { sessionsSkipped += 1; continue; }
188
+ const records = sessionToUsageRecords(session, issueIdentifier);
189
+ if (records.length === 0) { sessionsSkipped += 1; continue; }
190
+ await appendUsageRecords(outFile, records);
191
+ recordsWritten += records.length;
192
+ sessionsProcessed += 1;
193
+ }
194
+ onProgress({ phase: 'claude', file: dir, processed: i + 1, total: claudeDirs.length, recordsWritten });
195
+ }
196
+
197
+ // Phase 2: walk Codex rollouts.
198
+ const codexFiles = await listCodexRollouts(codexRootDir);
199
+ for (let i = 0; i < codexFiles.length; i++) {
200
+ const file = codexFiles[i];
201
+ const session = await parseCodexSession(file);
202
+ if (session === null) { sessionsSkipped += 1; continue; }
203
+ const issueIdentifier = issueFromCwd(session.cwd, cwdPattern);
204
+ if (issueIdentifier === null) { sessionsSkipped += 1; continue; }
205
+ const records = sessionToUsageRecords(session, issueIdentifier);
206
+ if (records.length === 0) { sessionsSkipped += 1; continue; }
207
+ await appendUsageRecords(outFile, records);
208
+ recordsWritten += records.length;
209
+ sessionsProcessed += 1;
210
+ if ((i + 1) % 100 === 0 || i + 1 === codexFiles.length) {
211
+ onProgress({ phase: 'codex', file, processed: i + 1, total: codexFiles.length, recordsWritten });
212
+ }
213
+ }
214
+
215
+ return { recordsWritten, sessionsProcessed, sessionsSkipped };
216
+ }
217
+
218
+ /**
219
+ * Enumerate every issue identifier that has at least one session in the
220
+ * configured transcript directories. Useful for `llm-cost list`.
221
+ *
222
+ * @param {object} [options] Same shape as computeIssueCost options.
223
+ */
224
+ export async function listKnownIssues(options = {}) {
225
+ const cwdPattern = options.cwdPattern ?? DEFAULT_CWD_PATTERN;
226
+ const claudeRootDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects');
227
+ const codexRootDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions');
228
+ const ids = new Set();
229
+
230
+ for (const dir of await findClaudeProjectDirs(claudeRootDir, () => true)) {
231
+ const dirName = dir.split('/').pop() ?? '';
232
+ const issue = issueFromClaudeProjectDirName(dirName, cwdPattern);
233
+ if (issue !== null) ids.add(issue);
234
+ }
235
+ for (const file of await listCodexRollouts(codexRootDir)) {
236
+ const session = await parseCodexSession(file);
237
+ if (session === null) continue;
238
+ const issue = issueFromCwd(session.cwd, cwdPattern);
239
+ if (issue !== null) ids.add(issue);
240
+ }
241
+
242
+ return [...ids].sort();
243
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Maps a working directory (the cwd at which a CLI session was launched) to
3
+ * an issue identifier. The mapping is convention-driven: the caller decides
4
+ * what shape their cwd takes; this module decides whether a given cwd
5
+ * belongs to a given issue.
6
+ *
7
+ * The default pattern matches the two most common `workspace.root` settings
8
+ * for a Symphony-conformant orchestrator
9
+ * (https://github.com/openai/symphony/blob/main/SPEC.md):
10
+ *
11
+ * 1. The spec default, `<system-temp>/symphony_workspaces/<ID>`
12
+ * (e.g. `/tmp/symphony_workspaces/EPAC-1940`).
13
+ * 2. The in-repo override, `<repo>/.symphony/workspaces/<ID>`
14
+ * (e.g. `/Users/x/code/repo/.symphony/workspaces/EPAC-1940`).
15
+ *
16
+ * Both place the workspace_key (the sanitized issue identifier) as the last
17
+ * path component of the cwd, satisfying the spec's Invariant 1
18
+ * (`cwd == workspace_path`). For other `workspace.root` settings, pass
19
+ * `--cwd-pattern '<regex>'` with one capture group for the issue ID.
20
+ *
21
+ * The pattern matches against either:
22
+ * - the FULL cwd string (raw, as recorded in Codex `session_meta.cwd`), or
23
+ * - the encoded Claude project directory name (where `/` and `.` have both
24
+ * been replaced by `-`).
25
+ *
26
+ * Examples of caller-supplied patterns:
27
+ * /\/issues\/([A-Z]+-\d+)$/ (matches `~/code/issues/PROJ-12`)
28
+ * /-([A-Z]+-\d+)$/ (matches any cwd ending `-PROJ-12`)
29
+ */
30
+
31
+ // Both Symphony-default `symphony_workspaces/<ID>` and the common in-repo
32
+ // `.symphony/workspaces/<ID>` form, in either raw or Claude-encoded shape
33
+ // (`/` and `.` both become `-` in Claude's project-dir encoding).
34
+ export const DEFAULT_CWD_PATTERN =
35
+ /(?:symphony_workspaces|[.\-]symphony[/-]workspaces)[/-]([A-Za-z0-9._-]+)$/;
36
+
37
+ /**
38
+ * Extract the issue identifier from a Codex-style raw cwd path
39
+ * (e.g. `/Users/sunny/code/repo/.symphony/workspaces/EPAC-1940`).
40
+ */
41
+ export function issueFromCwd(cwd, pattern) {
42
+ const m = pattern.exec(cwd);
43
+ return m === null ? null : (m[1] ?? null);
44
+ }
45
+
46
+ /**
47
+ * Extract the issue identifier from a Claude-encoded project directory name
48
+ * (e.g. `-Users-sunny-code-repo--symphony-workspaces-EPAC-1940`).
49
+ *
50
+ * Claude Code stores sessions under `~/.claude/projects/<encoded-cwd>/` and
51
+ * encodes the absolute cwd by replacing every `/` and `.` with `-`. The
52
+ * default pattern's character class `[.\-]` matches both the raw and
53
+ * encoded path separators in one regex.
54
+ */
55
+ export function issueFromClaudeProjectDirName(name, pattern) {
56
+ const m = pattern.exec(name);
57
+ return m === null ? null : (m[1] ?? null);
58
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Multi-issue rollup: aggregate cost across a set of issues specified as
3
+ * either explicit IDs (`EPAC-1940`) or inclusive ranges (`EPAC-1990-1999`).
4
+ *
5
+ * Range syntax exploits the fact that Linear issue keys are strictly
6
+ * `<TEAM>-<NUMBER>` (no other hyphens permitted in the key), so a
7
+ * positional matching `<PREFIX>-<START>-<END>` is unambiguously a range.
8
+ */
9
+
10
+ const SINGLE_ISSUE_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)$/;
11
+ const RANGE_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)-(\d+)$/;
12
+
13
+ /**
14
+ * Expand a single positional into a list of issue IDs.
15
+ *
16
+ * "EPAC-1940" → ["EPAC-1940"]
17
+ * "EPAC-1990-1999" → ["EPAC-1990", "EPAC-1991", ..., "EPAC-1999"]
18
+ *
19
+ * Throws if the positional doesn't match either shape, or if a range
20
+ * has start > end, or if the range is unreasonably large (> 10,000).
21
+ *
22
+ * @param {string} arg
23
+ * @returns {string[]}
24
+ */
25
+ export function expandIssueArg(arg) {
26
+ if (typeof arg !== 'string' || arg.length === 0) {
27
+ throw new Error(`empty issue argument`);
28
+ }
29
+ const normalized = arg.trim().toUpperCase();
30
+
31
+ const rangeMatch = RANGE_PATTERN.exec(normalized);
32
+ if (rangeMatch !== null) {
33
+ const prefix = rangeMatch[1];
34
+ const start = Number.parseInt(rangeMatch[2], 10);
35
+ const end = Number.parseInt(rangeMatch[3], 10);
36
+ if (start > end) {
37
+ throw new Error(`range start > end: ${arg} (parsed as ${start}..${end})`);
38
+ }
39
+ const count = end - start + 1;
40
+ if (count > 10_000) {
41
+ throw new Error(`range too large: ${arg} would expand to ${count} issues (cap is 10,000)`);
42
+ }
43
+ const out = new Array(count);
44
+ for (let i = 0; i < count; i++) out[i] = `${prefix}-${start + i}`;
45
+ return out;
46
+ }
47
+
48
+ const singleMatch = SINGLE_ISSUE_PATTERN.exec(normalized);
49
+ if (singleMatch !== null) {
50
+ return [normalized];
51
+ }
52
+
53
+ throw new Error(
54
+ `unrecognized issue argument: ${arg}` +
55
+ ` (expected <TEAM>-<N> or <TEAM>-<N>-<M>, e.g. EPAC-1940 or EPAC-1990-1999)`,
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Expand and deduplicate a list of positionals into a stable-ordered set.
61
+ *
62
+ * Order is: first appearance of each unique ID across the args. This way
63
+ * `./cost EPAC-1990-1999 EPAC-1995` doesn't double-count or shuffle.
64
+ *
65
+ * @param {readonly string[]} args
66
+ * @returns {{ ids: string[], requestedCount: number }}
67
+ * - ids: ordered unique issue identifiers
68
+ * - requestedCount: total IDs after range expansion (before dedup). Useful
69
+ * for "10 issues requested, 7 had data" framing when ranges are involved.
70
+ */
71
+ export function expandAllIssueArgs(args) {
72
+ const seen = new Set();
73
+ const ids = [];
74
+ let requestedCount = 0;
75
+ for (const arg of args) {
76
+ const expanded = expandIssueArg(arg);
77
+ requestedCount += expanded.length;
78
+ for (const id of expanded) {
79
+ if (!seen.has(id)) {
80
+ seen.add(id);
81
+ ids.push(id);
82
+ }
83
+ }
84
+ }
85
+ return { ids, requestedCount };
86
+ }
87
+
88
+ /**
89
+ * @typedef {object} PerIssueRow
90
+ * @property {string} issueIdentifier
91
+ * @property {number} sessionCount
92
+ * @property {number} turnCount
93
+ * @property {number} tokens Combined input + output across providers.
94
+ * @property {number|null} apiCostUsd Null if no rates known for any model used.
95
+ * @property {Record<string, {sessions: number, turns: number, tokens: number, costUsd: number|null}>} byProvider
96
+ */
97
+
98
+ /**
99
+ * @typedef {object} MultiIssueRollup
100
+ * @property {string} label Human-readable summary (e.g. "EPAC-1990-1999" or "3 issues").
101
+ * @property {string[]} positionals The raw args the user passed.
102
+ * @property {string[]} requestedIds Every unique ID after expansion.
103
+ * @property {number} requestedCount Sum of expanded args before dedup.
104
+ * @property {PerIssueRow[]} issues One row per ID that had any data.
105
+ * @property {string[]} missing IDs in requestedIds with no data.
106
+ * @property {PerIssueRow} totals Aggregate across `issues`.
107
+ */
108
+
109
+ /**
110
+ * Compute a rollup across multiple issue IDs.
111
+ *
112
+ * `loader` is `(issueId) => Promise<IssueRollup>` so this function works
113
+ * against either CLI transcripts or a usage.jsonl source — the caller
114
+ * passes whichever loader they want.
115
+ *
116
+ * @param {readonly string[]} positionals The user's raw positional args.
117
+ * @param {(id: string) => Promise<import('./aggregator.js').IssueRollup>} loader
118
+ * @returns {Promise<MultiIssueRollup>}
119
+ */
120
+ export async function computeMultiIssueRollup(positionals, loader) {
121
+ const { ids, requestedCount } = expandAllIssueArgs(positionals);
122
+
123
+ const issues = [];
124
+ const missing = [];
125
+
126
+ for (const id of ids) {
127
+ const rollup = await loader(id);
128
+ if (rollup.combinedSessions === 0 && rollup.combinedTurns === 0) {
129
+ missing.push(id);
130
+ continue;
131
+ }
132
+ issues.push(rollupToRow(id, rollup));
133
+ }
134
+
135
+ return {
136
+ label: labelFor(positionals),
137
+ positionals: [...positionals],
138
+ requestedIds: ids,
139
+ requestedCount,
140
+ issues,
141
+ missing,
142
+ totals: sumRows(issues),
143
+ };
144
+ }
145
+
146
+ function rollupToRow(issueIdentifier, rollup) {
147
+ const byProvider = {};
148
+ let totalCost = null;
149
+ let costKnown = true;
150
+ for (const provider of ['claude', 'codex']) {
151
+ const t = rollup.providerTotals[provider];
152
+ const cost = t.pricing?.totalUsd ?? null;
153
+ byProvider[provider] = {
154
+ sessions: t.sessionCount,
155
+ turns: t.turnCount,
156
+ tokens: t.tokensGrandTotal,
157
+ costUsd: cost,
158
+ };
159
+ if (t.sessionCount > 0 && cost === null) costKnown = false;
160
+ if (cost !== null) totalCost = (totalCost ?? 0) + cost;
161
+ }
162
+ return {
163
+ issueIdentifier,
164
+ sessionCount: rollup.combinedSessions,
165
+ turnCount: rollup.combinedTurns,
166
+ tokens: rollup.combinedTokens,
167
+ apiCostUsd: costKnown ? totalCost : null,
168
+ byProvider,
169
+ };
170
+ }
171
+
172
+ function sumRows(rows) {
173
+ let sessionCount = 0;
174
+ let turnCount = 0;
175
+ let tokens = 0;
176
+ let apiCostUsd = null;
177
+ let costKnown = true;
178
+ const byProvider = {
179
+ claude: { sessions: 0, turns: 0, tokens: 0, costUsd: null },
180
+ codex: { sessions: 0, turns: 0, tokens: 0, costUsd: null },
181
+ };
182
+ let claudeCost = null;
183
+ let codexCost = null;
184
+ for (const r of rows) {
185
+ sessionCount += r.sessionCount;
186
+ turnCount += r.turnCount;
187
+ tokens += r.tokens;
188
+ if (r.apiCostUsd === null) costKnown = false;
189
+ else apiCostUsd = (apiCostUsd ?? 0) + r.apiCostUsd;
190
+ for (const provider of ['claude', 'codex']) {
191
+ byProvider[provider].sessions += r.byProvider[provider].sessions;
192
+ byProvider[provider].turns += r.byProvider[provider].turns;
193
+ byProvider[provider].tokens += r.byProvider[provider].tokens;
194
+ const c = r.byProvider[provider].costUsd;
195
+ if (c !== null) {
196
+ if (provider === 'claude') claudeCost = (claudeCost ?? 0) + c;
197
+ else codexCost = (codexCost ?? 0) + c;
198
+ }
199
+ }
200
+ }
201
+ byProvider.claude.costUsd = claudeCost;
202
+ byProvider.codex.costUsd = codexCost;
203
+ return {
204
+ issueIdentifier: 'TOTAL',
205
+ sessionCount,
206
+ turnCount,
207
+ tokens,
208
+ apiCostUsd: costKnown ? apiCostUsd : null,
209
+ byProvider,
210
+ };
211
+ }
212
+
213
+ function labelFor(positionals) {
214
+ if (positionals.length === 1) return positionals[0].toUpperCase();
215
+ if (positionals.length <= 3) return positionals.map((p) => p.toUpperCase()).join(', ');
216
+ return `${positionals.length} arguments`;
217
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-model API pricing for Anthropic and OpenAI, expressed as USD per
3
+ * million tokens for each bucket the cost-telemetry spec breaks input
4
+ * tokens into (uncached / cache read / cache write 5m / cache write 1h)
5
+ * plus the single output rate.
6
+ *
7
+ * Rates are *list* prices from the respective providers' public pricing
8
+ * pages on `verifiedOn`. They DO NOT reflect:
9
+ * - Subscription plans (Claude Max, Codex Pro, etc.) — those are flat-rate
10
+ * and have no per-token component. API-equivalent dollars from this
11
+ * table are a *counterfactual*: what a token volume would have cost on
12
+ * pay-as-you-go API.
13
+ * - Volume discounts, enterprise rates, batch API discounts.
14
+ * - Provider promotional pricing or beta-tier rates.
15
+ *
16
+ * Update when providers change pricing. The CLI warns if a table entry's
17
+ * verifiedOn is more than STALE_AFTER_DAYS old.
18
+ */
19
+
20
+ export const STALE_AFTER_DAYS = 90;
21
+ const VERIFIED_ON = '2026-05-22';
22
+
23
+ /**
24
+ * @typedef {object} PricingEntry
25
+ * @property {'anthropic'|'openai'} provider
26
+ * @property {string} model Canonical model name as it appears in the table.
27
+ * @property {number} inputPerMillionUsd USD per 1M uncached input tokens.
28
+ * @property {number|null} cachedInputPerMillionUsd USD per 1M cache-read input tokens (null = provider doesn't price separately).
29
+ * @property {number|null} cacheWrite5mPerMillionUsd USD per 1M tokens written to the 5-minute ephemeral cache (Anthropic only).
30
+ * @property {number|null} cacheWrite1hPerMillionUsd USD per 1M tokens written to the 1-hour ephemeral cache (Anthropic only).
31
+ * @property {number} outputPerMillionUsd USD per 1M output tokens (reasoning + visible both billed at this rate).
32
+ * @property {string} verifiedOn ISO date string when the rate was checked against the source URL.
33
+ * @property {string} sourceUrl Provider's public pricing page.
34
+ * @property {string} [notes]
35
+ */
36
+
37
+ /** @type {readonly PricingEntry[]} */
38
+ export const PRICING_TABLE = Object.freeze([
39
+ // ── Anthropic — https://www.anthropic.com/pricing ─────────────────────
40
+ {
41
+ provider: 'anthropic', model: 'claude-opus-4.7',
42
+ inputPerMillionUsd: 5, cachedInputPerMillionUsd: 0.5,
43
+ cacheWrite5mPerMillionUsd: 6.25, cacheWrite1hPerMillionUsd: null,
44
+ outputPerMillionUsd: 25,
45
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
46
+ },
47
+ {
48
+ provider: 'anthropic', model: 'claude-sonnet-4.6',
49
+ inputPerMillionUsd: 3, cachedInputPerMillionUsd: 0.3,
50
+ cacheWrite5mPerMillionUsd: 3.75, cacheWrite1hPerMillionUsd: null,
51
+ outputPerMillionUsd: 15,
52
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
53
+ },
54
+ {
55
+ provider: 'anthropic', model: 'claude-haiku-4.5',
56
+ inputPerMillionUsd: 1, cachedInputPerMillionUsd: 0.1,
57
+ cacheWrite5mPerMillionUsd: 1.25, cacheWrite1hPerMillionUsd: null,
58
+ outputPerMillionUsd: 5,
59
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
60
+ },
61
+ {
62
+ provider: 'anthropic', model: 'claude-sonnet-4.5',
63
+ inputPerMillionUsd: 3, cachedInputPerMillionUsd: 0.3,
64
+ cacheWrite5mPerMillionUsd: 3.75, cacheWrite1hPerMillionUsd: null,
65
+ outputPerMillionUsd: 15,
66
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
67
+ },
68
+ {
69
+ provider: 'anthropic', model: 'claude-opus-4.6',
70
+ inputPerMillionUsd: 5, cachedInputPerMillionUsd: 0.5,
71
+ cacheWrite5mPerMillionUsd: 6.25, cacheWrite1hPerMillionUsd: null,
72
+ outputPerMillionUsd: 25,
73
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
74
+ },
75
+ {
76
+ provider: 'anthropic', model: 'claude-opus-4.5',
77
+ inputPerMillionUsd: 5, cachedInputPerMillionUsd: 0.5,
78
+ cacheWrite5mPerMillionUsd: 6.25, cacheWrite1hPerMillionUsd: null,
79
+ outputPerMillionUsd: 25,
80
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
81
+ },
82
+ {
83
+ provider: 'anthropic', model: 'claude-opus-4.1',
84
+ inputPerMillionUsd: 15, cachedInputPerMillionUsd: 1.5,
85
+ cacheWrite5mPerMillionUsd: 18.75, cacheWrite1hPerMillionUsd: null,
86
+ outputPerMillionUsd: 75,
87
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
88
+ },
89
+ {
90
+ provider: 'anthropic', model: 'claude-sonnet-4',
91
+ inputPerMillionUsd: 3, cachedInputPerMillionUsd: 0.3,
92
+ cacheWrite5mPerMillionUsd: 3.75, cacheWrite1hPerMillionUsd: null,
93
+ outputPerMillionUsd: 15,
94
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
95
+ },
96
+ {
97
+ provider: 'anthropic', model: 'claude-opus-4',
98
+ inputPerMillionUsd: 15, cachedInputPerMillionUsd: 1.5,
99
+ cacheWrite5mPerMillionUsd: 18.75, cacheWrite1hPerMillionUsd: null,
100
+ outputPerMillionUsd: 75,
101
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://www.anthropic.com/pricing',
102
+ },
103
+
104
+ // ── OpenAI — https://platform.openai.com/docs/pricing ─────────────────
105
+ {
106
+ provider: 'openai', model: 'gpt-5.5',
107
+ inputPerMillionUsd: 5, cachedInputPerMillionUsd: 0.5,
108
+ cacheWrite5mPerMillionUsd: null, cacheWrite1hPerMillionUsd: null,
109
+ outputPerMillionUsd: 30,
110
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://platform.openai.com/docs/pricing',
111
+ },
112
+ {
113
+ provider: 'openai', model: 'gpt-5.4',
114
+ inputPerMillionUsd: 2.5, cachedInputPerMillionUsd: 0.25,
115
+ cacheWrite5mPerMillionUsd: null, cacheWrite1hPerMillionUsd: null,
116
+ outputPerMillionUsd: 15,
117
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://platform.openai.com/docs/pricing',
118
+ },
119
+ {
120
+ provider: 'openai', model: 'gpt-5.4-mini',
121
+ inputPerMillionUsd: 0.75, cachedInputPerMillionUsd: 0.075,
122
+ cacheWrite5mPerMillionUsd: null, cacheWrite1hPerMillionUsd: null,
123
+ outputPerMillionUsd: 4.5,
124
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://platform.openai.com/docs/pricing',
125
+ },
126
+ {
127
+ provider: 'openai', model: 'gpt-5.4-nano',
128
+ inputPerMillionUsd: 0.2, cachedInputPerMillionUsd: 0.02,
129
+ cacheWrite5mPerMillionUsd: null, cacheWrite1hPerMillionUsd: null,
130
+ outputPerMillionUsd: 1.25,
131
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://platform.openai.com/docs/pricing',
132
+ },
133
+ {
134
+ provider: 'openai', model: 'gpt-5.3-codex',
135
+ inputPerMillionUsd: 1.75, cachedInputPerMillionUsd: 0.175,
136
+ cacheWrite5mPerMillionUsd: null, cacheWrite1hPerMillionUsd: null,
137
+ outputPerMillionUsd: 14,
138
+ verifiedOn: VERIFIED_ON, sourceUrl: 'https://platform.openai.com/docs/pricing',
139
+ },
140
+ ]);