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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Read and write `usage.jsonl` records as defined by the Symphony
3
+ * Coding-Agent Cost Telemetry Extension specification:
4
+ *
5
+ * https://github.com/RiddimSoftware/groove/blob/main/specs/symphony-cost-telemetry-extension/SPEC.md
6
+ *
7
+ * One JSON object per line, UTF-8, LF-terminated. Records are append-only
8
+ * and never modified once written.
9
+ *
10
+ * The spec's canonical location is
11
+ * <symphony-workspace-root>/.symphony/telemetry/usage.jsonl
12
+ * but writers MAY split across files matching `usage*.jsonl` (or `.jsonl.gz`)
13
+ * in the same directory; readers MUST treat the concatenation as one stream.
14
+ * This module's `readUsageRecords` walks any directory and concatenates all
15
+ * matching plain-JSONL files. (`.jsonl.gz` is not handled yet — see TODO.)
16
+ */
17
+ import { createWriteStream } from 'node:fs';
18
+ import { readdir, stat } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { readJsonl } from './util.mjs';
21
+
22
+ /** The schemaVersion this module writes. */
23
+ export const SCHEMA_VERSION = 1;
24
+
25
+ /**
26
+ * Walk a directory and return every plain-JSONL file matching `usage*.jsonl`.
27
+ * Used by readers to honor the spec's "writers MAY split, readers concatenate"
28
+ * rule. Returns absolute paths in directory-listing order.
29
+ *
30
+ * @param {string} dir
31
+ */
32
+ export async function findUsageFiles(dir) {
33
+ let entries;
34
+ try {
35
+ entries = await readdir(dir, { withFileTypes: true });
36
+ } catch {
37
+ return [];
38
+ }
39
+ const out = [];
40
+ for (const e of entries) {
41
+ if (!e.isFile()) continue;
42
+ if (!e.name.startsWith('usage')) continue;
43
+ if (!e.name.endsWith('.jsonl')) continue; // .jsonl.gz: TODO
44
+ out.push(join(dir, e.name));
45
+ }
46
+ return out.sort();
47
+ }
48
+
49
+ /**
50
+ * Stream every usage record from a path. The path may be:
51
+ * - a single .jsonl file
52
+ * - a directory containing one or more `usage*.jsonl` files
53
+ *
54
+ * Yields the records as plain objects. Records the reader doesn't recognize
55
+ * (e.g. schemaVersion > 1) are still yielded — callers should check the
56
+ * version themselves per spec §6.4.
57
+ *
58
+ * @param {string} pathOrDir
59
+ */
60
+ export async function *readUsageRecords(pathOrDir) {
61
+ const files = await resolveUsageFiles(pathOrDir);
62
+ for (const file of files) {
63
+ for await (const rec of readJsonl(file)) {
64
+ yield rec;
65
+ }
66
+ }
67
+ }
68
+
69
+ async function resolveUsageFiles(pathOrDir) {
70
+ let info;
71
+ try {
72
+ info = await stat(pathOrDir);
73
+ } catch {
74
+ return [];
75
+ }
76
+ if (info.isFile()) return [pathOrDir];
77
+ if (info.isDirectory()) return findUsageFiles(pathOrDir);
78
+ return [];
79
+ }
80
+
81
+ /**
82
+ * Append a batch of usage records to a single .jsonl file. Writes are
83
+ * line-buffered and synchronous from the caller's perspective.
84
+ *
85
+ * @param {string} outFile
86
+ * @param {Iterable<object>} records
87
+ */
88
+ export async function appendUsageRecords(outFile, records) {
89
+ const stream = createWriteStream(outFile, { flags: 'a', encoding: 'utf8' });
90
+ try {
91
+ for (const rec of records) {
92
+ const line = JSON.stringify(rec) + '\n';
93
+ if (!stream.write(line)) {
94
+ await new Promise((resolveDrain) => stream.once('drain', resolveDrain));
95
+ }
96
+ }
97
+ } finally {
98
+ await new Promise((resolveEnd, rejectEnd) => {
99
+ stream.end((err) => (err ? rejectEnd(err) : resolveEnd()));
100
+ });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Validate the REQUIRED fields of a usage record per spec §5.1. Returns
106
+ * `null` for a valid record, or a human-readable reason string otherwise.
107
+ * Used by readers to drop malformed lines without crashing.
108
+ *
109
+ * @param {unknown} rec
110
+ */
111
+ export function validateUsageRecord(rec) {
112
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return 'not a JSON object';
113
+ const r = /** @type {Record<string, unknown>} */ (rec);
114
+ if (typeof r.schemaVersion !== 'number' || !Number.isInteger(r.schemaVersion) || r.schemaVersion < 1) return 'schemaVersion missing or invalid';
115
+ if (typeof r.recordedAt !== 'string') return 'recordedAt missing';
116
+ if (typeof r.runID !== 'string') return 'runID missing';
117
+ if (typeof r.turn !== 'number' || !Number.isInteger(r.turn) || r.turn < 1) return 'turn missing or invalid';
118
+ if (typeof r.issueIdentifier !== 'string' || r.issueIdentifier === '') return 'issueIdentifier missing';
119
+ if (typeof r.provider !== 'string' || r.provider === '') return 'provider missing';
120
+ if (typeof r.model !== 'string') return 'model missing';
121
+ if (r.botRole !== 'developer' && r.botRole !== 'reviewer') return 'botRole missing or invalid';
122
+ const us = r.usageSource;
123
+ if (us !== 'provider_reported' && us !== 'estimated' && us !== 'unavailable') return 'usageSource missing or invalid';
124
+ const okInt = (v) => v === null || (typeof v === 'number' && Number.isInteger(v) && v >= 0);
125
+ if (!okInt(r.inputTokens)) return 'inputTokens missing or invalid';
126
+ if (!okInt(r.outputTokens)) return 'outputTokens missing or invalid';
127
+ if (!okInt(r.totalTokens)) return 'totalTokens missing or invalid';
128
+ if (us === 'unavailable' && (r.inputTokens !== null || r.outputTokens !== null || r.totalTokens !== null)) {
129
+ return 'unavailable record must have null token counts';
130
+ }
131
+ if (typeof r.startedAt !== 'string') return 'startedAt missing';
132
+ if (typeof r.endedAt !== 'string') return 'endedAt missing';
133
+ return null;
134
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+
5
+ export async function pathExists(path) {
6
+ try {
7
+ await stat(path);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export async function *readJsonl(path) {
15
+ if (!(await pathExists(path))) return;
16
+ const stream = createReadStream(path, { encoding: 'utf8' });
17
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
18
+ for await (const line of lines) {
19
+ if (line.trim() === '') continue;
20
+ try {
21
+ const rec = JSON.parse(line);
22
+ if (rec !== null && typeof rec === 'object' && !Array.isArray(rec)) {
23
+ yield rec;
24
+ }
25
+ } catch {
26
+ // skip malformed
27
+ }
28
+ }
29
+ }
30
+
31
+ export function numericOrZero(value) {
32
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
33
+ }
34
+
35
+ export function formatNumber(n) {
36
+ return n.toLocaleString('en-US');
37
+ }
38
+
39
+ export function formatDuration(ms) {
40
+ if (!Number.isFinite(ms) || ms < 0) return '-';
41
+ const totalSec = Math.round(ms / 1000);
42
+ const h = Math.floor(totalSec / 3600);
43
+ const m = Math.floor((totalSec % 3600) / 60);
44
+ const s = totalSec % 60;
45
+ if (h > 0) return `${h}h ${m}m ${s}s`;
46
+ if (m > 0) return `${m}m ${s}s`;
47
+ return `${s}s`;
48
+ }