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/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
|
+
]);
|