sidekick-shared 0.28.3 → 0.29.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +149 -19
  3. package/dist/authStatus.d.ts +54 -0
  4. package/dist/authStatus.js +91 -0
  5. package/dist/browser.d.ts +12 -6
  6. package/dist/browser.js +17 -1
  7. package/dist/codex.d.ts +23 -0
  8. package/dist/codex.js +44 -0
  9. package/dist/codexProfiles.d.ts +1 -0
  10. package/dist/codexProfiles.js +8 -11
  11. package/dist/esm/authStatus.js +87 -0
  12. package/dist/esm/browser.js +4 -2
  13. package/dist/esm/codex.js +18 -0
  14. package/dist/esm/codexProfiles.js +8 -11
  15. package/dist/esm/formatting.js +10 -1
  16. package/dist/esm/index.js +9 -6
  17. package/dist/esm/modelCatalog.js +2 -1
  18. package/dist/esm/modelInfo.js +422 -45
  19. package/dist/esm/node.js +9 -3
  20. package/dist/esm/parsers/codexParser.js +175 -54
  21. package/dist/esm/parsers/codexRolloutOrigin.js +107 -0
  22. package/dist/esm/parsers/openCodeParser.js +83 -21
  23. package/dist/esm/pricing.js +18 -0
  24. package/dist/esm/pricingCatalog.js +14 -7
  25. package/dist/esm/promptHistory.js +66 -4
  26. package/dist/esm/providerFailure.js +124 -3
  27. package/dist/esm/providers/codex.js +2 -24
  28. package/dist/esm/providers/codexDatabase.js +3 -19
  29. package/dist/esm/providers/codexRollouts.js +24 -1
  30. package/dist/esm/providers/openCode.js +9 -17
  31. package/dist/esm/report/htmlReportGenerator.js +42 -5
  32. package/dist/esm/report/transcriptParser.js +13 -11
  33. package/dist/esm/schemas/providerFailure.js +3 -1
  34. package/dist/esm/schemas/transcript.js +4 -0
  35. package/dist/esm/sessionPromptHistory.js +2 -2
  36. package/dist/esm/statusline/payload.js +9 -0
  37. package/dist/esm/testing/credentialGuard.js +87 -5
  38. package/dist/esm/toolCallOrigins.js +310 -0
  39. package/dist/esm/transcript.js +42 -3
  40. package/dist/esm/usage/usageEvents.js +146 -17
  41. package/dist/esm/usage/usageReports.js +39 -6
  42. package/dist/esm/watchers/jsonlTail.js +378 -59
  43. package/dist/formatting.d.ts +26 -1
  44. package/dist/formatting.js +11 -1
  45. package/dist/index.d.ts +16 -10
  46. package/dist/index.js +35 -12
  47. package/dist/modelCatalog.js +2 -1
  48. package/dist/modelInfo.d.ts +185 -16
  49. package/dist/modelInfo.js +431 -47
  50. package/dist/node.d.ts +16 -5
  51. package/dist/node.js +31 -2
  52. package/dist/parsers/codexParser.d.ts +55 -0
  53. package/dist/parsers/codexParser.js +175 -54
  54. package/dist/parsers/codexRolloutOrigin.d.ts +49 -0
  55. package/dist/parsers/codexRolloutOrigin.js +111 -0
  56. package/dist/parsers/openCodeParser.d.ts +38 -0
  57. package/dist/parsers/openCodeParser.js +86 -21
  58. package/dist/pricing.d.ts +22 -0
  59. package/dist/pricing.js +50 -0
  60. package/dist/pricingCatalog.d.ts +10 -0
  61. package/dist/pricingCatalog.js +13 -6
  62. package/dist/promptHistory.d.ts +65 -4
  63. package/dist/promptHistory.js +68 -5
  64. package/dist/providerFailure.d.ts +44 -0
  65. package/dist/providerFailure.js +126 -4
  66. package/dist/providers/codex.d.ts +3 -22
  67. package/dist/providers/codex.js +3 -26
  68. package/dist/providers/codexDatabase.d.ts +3 -2
  69. package/dist/providers/codexDatabase.js +4 -22
  70. package/dist/providers/codexRollouts.d.ts +22 -0
  71. package/dist/providers/codexRollouts.js +24 -0
  72. package/dist/providers/openCode.js +8 -16
  73. package/dist/report/htmlReportGenerator.js +42 -5
  74. package/dist/report/transcriptParser.d.ts +10 -1
  75. package/dist/report/transcriptParser.js +13 -11
  76. package/dist/schemas/providerFailure.d.ts +39 -0
  77. package/dist/schemas/providerFailure.js +2 -0
  78. package/dist/schemas/transcript.d.ts +2 -0
  79. package/dist/schemas/transcript.js +4 -0
  80. package/dist/sessionPromptHistory.d.ts +7 -1
  81. package/dist/sessionPromptHistory.js +1 -1
  82. package/dist/statusline/payload.d.ts +10 -0
  83. package/dist/statusline/payload.js +14 -0
  84. package/dist/testing/credentialGuard.d.ts +14 -0
  85. package/dist/testing/credentialGuard.js +87 -5
  86. package/dist/toolCallOrigins.d.ts +70 -0
  87. package/dist/toolCallOrigins.js +346 -0
  88. package/dist/transcript.d.ts +12 -0
  89. package/dist/transcript.js +42 -3
  90. package/dist/types/opencode.d.ts +2 -0
  91. package/dist/usage/usageEvents.d.ts +54 -6
  92. package/dist/usage/usageEvents.js +146 -17
  93. package/dist/usage/usageReports.d.ts +35 -4
  94. package/dist/usage/usageReports.js +39 -6
  95. package/dist/watchers/jsonlTail.d.ts +101 -4
  96. package/dist/watchers/jsonlTail.js +380 -60
  97. package/package.json +41 -1
@@ -12,9 +12,19 @@
12
12
  * every hit and pruned least-recently-used, once per collection.
13
13
  *
14
14
  * A Claude Code session's subagents write their own transcripts under
15
- * `<sessionId>/subagents/`; their usage is attributed to the parent session
16
- * and their sizes and mtimes join the fingerprint. (Codex spawned subagents
17
- * are top-level rollouts, already listed as sessions of their own.)
15
+ * `<sessionId>/subagents/`; their usage is attributed to the parent session,
16
+ * tagged with the subagent, and their sizes and mtimes join the fingerprint.
17
+ * (Codex spawned subagents and forks are top-level rollouts, already listed
18
+ * as sessions of their own; their events are tagged with the parent read
19
+ * from the rollout's first line.)
20
+ *
21
+ * Each event also carries the ordinal of the human prompt it answered, from
22
+ * the same scanner as `collectPromptHistory()`, so usage joins prompt history
23
+ * on `(provider, sessionId, ordinal)`.
24
+ *
25
+ * Cache files are named per cache version (`<provider>--<session>.v3.json`)
26
+ * so readers of different versions sharing the directory never overwrite each
27
+ * other's files; pruning ages out every version's files alike.
18
28
  *
19
29
  * Node-only: reads session files and the cache directory.
20
30
  *
@@ -60,7 +70,10 @@ exports.pruneUsageCache = pruneUsageCache;
60
70
  exports.collectUsageEvents = collectUsageEvents;
61
71
  const fs = __importStar(require("fs"));
62
72
  const path = __importStar(require("path"));
73
+ const codexRolloutOrigin_1 = require("../parsers/codexRolloutOrigin");
63
74
  const paths_1 = require("../paths");
75
+ const promptHistory_1 = require("../promptHistory");
76
+ const codexRollouts_1 = require("../providers/codexRollouts");
64
77
  const sessionPreviews_1 = require("../sessionPreviews");
65
78
  const usageNormalization_1 = require("../usageNormalization");
66
79
  const atomic_1 = require("../writers/atomic");
@@ -68,9 +81,11 @@ const workspaceRoots_1 = require("../workspaceRoots");
68
81
  /**
69
82
  * Bump when the cached record shape or the counting rules change; older files
70
83
  * are re-read. v2: Claude split lines and duplicate Codex `token_count` events
71
- * are counted once, and Claude subagent transcripts are included.
84
+ * are counted once, and Claude subagent transcripts are included. v3: events
85
+ * keep reasoning tokens, prompt ordinal, message id, and subagent, and files
86
+ * move to a versioned name (`<provider>--<session>.v3.json`).
72
87
  */
73
- exports.USAGE_CACHE_VERSION = 2;
88
+ exports.USAGE_CACHE_VERSION = 3;
74
89
  /**
75
90
  * Most-recently-used cache files kept on disk. Sized for a busy month of
76
91
  * sessions (files are a few KB each); a collection larger than this still
@@ -81,9 +96,15 @@ exports.MAX_USAGE_CACHE_FILES = 5000;
81
96
  function getUsageCacheDir() {
82
97
  return path.join((0, paths_1.getConfigDir)(), 'usage-cache');
83
98
  }
99
+ /**
100
+ * Cache file of one session. The name carries the cache version, so a reader
101
+ * of another version sharing the directory (which rejects this version, and
102
+ * versions before 3 use the unversioned `<provider>--<session>.json`) never
103
+ * overwrites it, and this version never overwrites theirs.
104
+ */
84
105
  function cacheFilePath(dir, provider, sessionId) {
85
106
  const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
86
- return path.join(dir, `${provider}--${safe}.json`);
107
+ return path.join(dir, `${provider}--${safe}.v${exports.USAGE_CACHE_VERSION}.json`);
87
108
  }
88
109
  function toMs(value, label) {
89
110
  if (value === undefined)
@@ -114,7 +135,11 @@ function readCache(filePath) {
114
135
  return null;
115
136
  }
116
137
  }
117
- /** Deletes all but the `keep` most recently used cache files (by mtime). Best-effort. */
138
+ /**
139
+ * Deletes all but the `keep` most recently used cache files (by mtime), of
140
+ * every cache version alike, so files no reader touches any more age out.
141
+ * Best-effort.
142
+ */
118
143
  function pruneUsageCache(keep = exports.MAX_USAGE_CACHE_FILES, dir = getUsageCacheDir()) {
119
144
  try {
120
145
  const entries = fs
@@ -147,7 +172,7 @@ function pruneUsageCache(keep = exports.MAX_USAGE_CACHE_FILES, dir = getUsageCac
147
172
  return 0;
148
173
  }
149
174
  }
150
- function cachedEventFromUsage(usage, timestamp, model) {
175
+ function cachedEventFromUsage(usage, timestamp, model, messageId, agentId) {
151
176
  const record = {
152
177
  t: timestamp,
153
178
  m: model,
@@ -160,16 +185,29 @@ function cachedEventFromUsage(usage, timestamp, model) {
160
185
  };
161
186
  if (usage.reportedCostUsd !== undefined)
162
187
  record.rc = usage.reportedCostUsd;
188
+ if (usage.reasoningTokens > 0)
189
+ record.rt = usage.reasoningTokens;
190
+ if (messageId && !messageId.startsWith('token-count-'))
191
+ record.rid = messageId;
192
+ if (agentId)
193
+ record.sa = agentId;
163
194
  return record;
164
195
  }
165
- /** Price a cached event with the current catalog (or its provider-reported cost). */
166
- function priceCachedEvent(cached, provider, sessionId, project, sessionFile) {
196
+ /**
197
+ * Price a cached event with the current catalog (or its provider-reported
198
+ * cost). Output is priced from the billable output (`bo`), which already
199
+ * holds reasoning however the provider reports it, so restoring the reasoning
200
+ * count never changes the cost.
201
+ */
202
+ function priceCachedEvent(cached, provider, sessionId, project, sessionFile, sessionSubagent) {
203
+ const reasoningTokens = typeof cached.rt === 'number' && cached.rt > 0 ? cached.rt : 0;
167
204
  const usage = {
168
205
  uncachedInputTokens: cached.i,
169
206
  cacheReadTokens: cached.cr,
170
207
  cacheWriteTokens: cached.cw,
171
208
  outputTokens: cached.o,
172
- reasoningTokens: 0,
209
+ reasoningTokens,
210
+ // `bo` is already the billable output; nothing is added to it.
173
211
  reasoningIncludedInOutput: true,
174
212
  cacheInclusiveInputTokens: cached.i + cached.cr + cached.cw,
175
213
  billableOutputTokens: cached.bo,
@@ -181,6 +219,9 @@ function priceCachedEvent(cached, provider, sessionId, project, sessionFile) {
181
219
  usage,
182
220
  modelId: cached.m === 'unknown' ? undefined : cached.m,
183
221
  });
222
+ const subagent = typeof cached.sa === 'string' && cached.sa
223
+ ? { agentId: cached.sa, parentSessionId: sessionId }
224
+ : sessionSubagent;
184
225
  return {
185
226
  timestamp: cached.t,
186
227
  provider,
@@ -193,13 +234,19 @@ function priceCachedEvent(cached, provider, sessionId, project, sessionFile) {
193
234
  cacheWriteTokens: cached.cw,
194
235
  cacheReadTokens: cached.cr,
195
236
  totalTokens: cached.tt,
237
+ ...(reasoningTokens > 0 ? { reasoningTokens } : {}),
196
238
  },
197
239
  costUsd: priced.costUsd,
198
240
  costProvenance: priced.source,
199
241
  filePath: sessionFile,
242
+ ...(cached.po === null || (typeof cached.po === 'number' && Number.isInteger(cached.po))
243
+ ? { promptOrdinal: cached.po }
244
+ : {}),
245
+ ...(typeof cached.rid === 'string' ? { requestId: cached.rid } : {}),
246
+ ...(subagent ? { subagent: { ...subagent } } : {}),
200
247
  };
201
248
  }
202
- const CLAUDE_AGENT_FILE = /^agent-.+\.jsonl$/;
249
+ const CLAUDE_AGENT_FILE = /^agent-(.+)\.jsonl$/;
203
250
  /** Claude Code subagent transcripts of a session, sorted by name. */
204
251
  function claudeSubagentFiles(preview) {
205
252
  if (preview.provider !== 'claude-code')
@@ -219,6 +266,7 @@ function claudeSubagentFiles(preview) {
219
266
  files.push({
220
267
  path: path.join(dir, name),
221
268
  name,
269
+ agentId: CLAUDE_AGENT_FILE.exec(name)?.[1] ?? name,
222
270
  sizeBytes: stat.size,
223
271
  modifiedAt: stat.mtimeMs,
224
272
  });
@@ -235,7 +283,7 @@ function sessionFingerprint(preview, subagents) {
235
283
  return base;
236
284
  return `${base}|${subagents.map((f) => `${f.name}:${f.sizeBytes}:${f.modifiedAt}`).join(',')}`;
237
285
  }
238
- function readTranscriptUsage(provider, filePath, cached) {
286
+ function readTranscriptUsage(provider, filePath, cached, agentId) {
239
287
  const reader = provider.createReader(filePath);
240
288
  const events = reader.readAll();
241
289
  reader.flush();
@@ -257,12 +305,14 @@ function readTranscriptUsage(provider, filePath, cached) {
257
305
  target.cr += usage.cacheReadTokens;
258
306
  target.bo += usage.billableOutputTokens;
259
307
  target.tt += usage.totalTokens;
308
+ if (usage.reasoningTokens > 0)
309
+ target.rt = (target.rt ?? 0) + usage.reasoningTokens;
260
310
  continue;
261
311
  }
262
312
  const timestamp = Date.parse(event.timestamp);
263
313
  if (!Number.isFinite(timestamp))
264
314
  continue;
265
- const record = cachedEventFromUsage(usage, timestamp, event.message?.model ?? usage.model ?? 'unknown');
315
+ const record = cachedEventFromUsage(usage, timestamp, event.message?.model ?? usage.model ?? 'unknown', messageId, agentId);
266
316
  cached.push(record);
267
317
  if (messageId)
268
318
  byMessageId.set(messageId, record);
@@ -272,10 +322,75 @@ function readSessionUsage(provider, preview, subagents) {
272
322
  const cached = [];
273
323
  readTranscriptUsage(provider, preview.filePath, cached);
274
324
  for (const file of subagents)
275
- readTranscriptUsage(provider, file.path, cached);
325
+ readTranscriptUsage(provider, file.path, cached, file.agentId);
276
326
  cached.sort((a, b) => a.t - b.t);
277
327
  return cached;
278
328
  }
329
+ /**
330
+ * Sets each event's `po` to the highest ordinal among the prompts timestamped
331
+ * at or before it, or null when none is. With prompts in time order (the
332
+ * usual case) that is the last prompt before the event.
333
+ */
334
+ function assignPromptOrdinals(events, marks) {
335
+ const sorted = [...marks].sort((a, b) => a.timestampMs - b.timestampMs || a.ordinal - b.ordinal);
336
+ const highest = [];
337
+ let max = -1;
338
+ for (const mark of sorted) {
339
+ max = Math.max(max, mark.ordinal);
340
+ highest.push(max);
341
+ }
342
+ for (const event of events) {
343
+ let low = 0;
344
+ let high = sorted.length;
345
+ while (low < high) {
346
+ const middle = (low + high) >>> 1;
347
+ if (sorted[middle].timestampMs <= event.t)
348
+ low = middle + 1;
349
+ else
350
+ high = middle;
351
+ }
352
+ event.po = low === 0 ? null : highest[low - 1];
353
+ }
354
+ }
355
+ /**
356
+ * Prompt ordinals for a freshly read session, from its main transcript.
357
+ * Returns false when the transcript could not be scanned (the events then
358
+ * keep no ordinal and the session is not cached, so the next run retries).
359
+ */
360
+ async function addPromptOrdinals(preview, events) {
361
+ if (preview.provider !== 'claude-code' && preview.provider !== 'codex')
362
+ return true;
363
+ let marks;
364
+ try {
365
+ marks = await (0, promptHistory_1.readPromptOrdinalMarks)(preview.provider, preview.sessionId, preview.filePath);
366
+ }
367
+ catch {
368
+ return false;
369
+ }
370
+ assignPromptOrdinals(events, marks);
371
+ return true;
372
+ }
373
+ /** Parent of a Codex rollout that is a fork or a spawned subagent, from its first line only. */
374
+ async function codexSessionSubagent(preview) {
375
+ if (preview.provider !== 'codex')
376
+ return undefined;
377
+ const { line } = await (0, codexRollouts_1.readFirstLineAsync)(preview.filePath);
378
+ if (!line)
379
+ return undefined;
380
+ let first;
381
+ try {
382
+ first = JSON.parse(line);
383
+ }
384
+ catch {
385
+ return undefined;
386
+ }
387
+ const origin = (0, codexRolloutOrigin_1.codexRolloutOrigin)(first);
388
+ if (!origin.parentId || (!origin.isSubagent && !origin.forkedFromId))
389
+ return undefined;
390
+ if (origin.parentId === preview.sessionId)
391
+ return undefined;
392
+ return { agentId: preview.sessionId, parentSessionId: origin.parentId };
393
+ }
279
394
  function yieldToEventLoop() {
280
395
  return new Promise((resolve) => setImmediate(resolve));
281
396
  }
@@ -315,12 +430,14 @@ async function collectUsageEvents(options) {
315
430
  const fingerprint = sessionFingerprint(preview, subagentFiles);
316
431
  const filePath = cacheFilePath(cacheDir, preview.provider, preview.sessionId);
317
432
  let cachedEvents = null;
433
+ let sessionSubagent;
318
434
  let project = preview.workspacePath;
319
435
  let fromCache = false;
320
436
  if (useCache) {
321
437
  const cached = readCache(filePath);
322
438
  if (cached && cached.fingerprint === fingerprint && cached.sessionId === preview.sessionId) {
323
439
  cachedEvents = cached.events;
440
+ sessionSubagent = validSubagent(cached.subagent);
324
441
  project = project ?? cached.project;
325
442
  fromCache = true;
326
443
  cacheHits += 1;
@@ -344,7 +461,10 @@ async function collectUsageEvents(options) {
344
461
  options.onDiagnostic?.(diagnostic);
345
462
  continue;
346
463
  }
347
- if (useCache) {
464
+ // Ordinals and origin are best-effort: a failure costs only the cache write.
465
+ const ordinalsRead = await addPromptOrdinals(preview, cachedEvents);
466
+ sessionSubagent = await codexSessionSubagent(preview).catch(() => undefined);
467
+ if (useCache && ordinalsRead) {
348
468
  const record = {
349
469
  version: exports.USAGE_CACHE_VERSION,
350
470
  provider: preview.provider,
@@ -352,6 +472,7 @@ async function collectUsageEvents(options) {
352
472
  fingerprint,
353
473
  project,
354
474
  events: cachedEvents,
475
+ ...(sessionSubagent ? { subagent: sessionSubagent } : {}),
355
476
  };
356
477
  try {
357
478
  await (0, atomic_1.atomicWriteJson)(filePath, record);
@@ -371,7 +492,7 @@ async function collectUsageEvents(options) {
371
492
  continue;
372
493
  if (untilMs !== null && cached.t > untilMs)
373
494
  continue;
374
- events.push(priceCachedEvent(cached, preview.provider, preview.sessionId, project, preview.filePath));
495
+ events.push(priceCachedEvent(cached, preview.provider, preview.sessionId, project, preview.filePath, sessionSubagent));
375
496
  first = first === null ? cached.t : Math.min(first, cached.t);
376
497
  last = last === null ? cached.t : Math.max(last, cached.t);
377
498
  count += 1;
@@ -400,6 +521,14 @@ async function collectUsageEvents(options) {
400
521
  ...(scope ? { roots: scope.roots } : {}),
401
522
  };
402
523
  }
524
+ function validSubagent(value) {
525
+ if (typeof value !== 'object' || value === null)
526
+ return undefined;
527
+ const { agentId, parentSessionId } = value;
528
+ return typeof agentId === 'string' && typeof parentSessionId === 'string'
529
+ ? { agentId, parentSessionId }
530
+ : undefined;
531
+ }
403
532
  /** The roots to list, or undefined for an unscoped (or legacy single-root) call. */
404
533
  function usageScope(options) {
405
534
  if (options.workspacePaths) {
@@ -10,8 +10,11 @@ import type { ProviderId } from '../providers/types';
10
10
  import type { TokenTotals } from '../types/historicalData';
11
11
  import type { UsageEventRecord } from './usageEvents';
12
12
  export type UsageGranularity = 'day' | 'week' | 'month' | 'session';
13
- /** Extra dimensions a bucket is split by, beyond the time key. */
14
- export type UsageGroupDimension = 'provider' | 'project' | 'model';
13
+ /**
14
+ * Extra dimensions a bucket is split by, beyond the time key. `session`
15
+ * splits each time bucket per session (rows then carry `sessionId`).
16
+ */
17
+ export type UsageGroupDimension = 'provider' | 'project' | 'model' | 'session';
15
18
  export interface BucketUsageOptions {
16
19
  granularity: UsageGranularity;
17
20
  /** Key by the UTC calendar instead of the local calendar (default false). */
@@ -20,6 +23,19 @@ export interface BucketUsageOptions {
20
23
  weekStartsOn?: 0 | 1;
21
24
  /** Dimensions to group by in addition to the time key (default `['provider']`). */
22
25
  groupBy?: readonly UsageGroupDimension[];
26
+ /**
27
+ * Drop events whose model is Claude Code's `<synthetic>` placeholder
28
+ * (`SYNTHETIC_MODEL`: locally generated, unbilled messages). Default false.
29
+ */
30
+ excludeSynthetic?: boolean;
31
+ /**
32
+ * How model ids are keyed in `model` rows and the `models` list. `'raw'`
33
+ * (default) keeps each id as recorded; `'canonical'` merges spellings of one
34
+ * model (date suffixes, provider/region prefixes, Bedrock forms) through
35
+ * `canonicalModelId()`, so `claude-opus-5-5-20261001` and `claude-opus-5-5`
36
+ * share a row. Pricing is unaffected: events arrive already priced.
37
+ */
38
+ modelKey?: 'raw' | 'canonical';
23
39
  }
24
40
  export interface UsageBucketRow {
25
41
  /** `YYYY-MM-DD` for days and week starts, `YYYY-MM` for months, the session id for sessions. */
@@ -29,7 +45,7 @@ export interface UsageBucketRow {
29
45
  project: string | null;
30
46
  /** Set when grouping by model; null for rows spanning every model. */
31
47
  model: string | null;
32
- /** Session id for `session` rows; null otherwise. */
48
+ /** Session id for `session` rows and rows grouped by `session`; null otherwise. */
33
49
  sessionId: string | null;
34
50
  /** Models seen in the row, most tokens first. */
35
51
  models: string[];
@@ -41,8 +57,18 @@ export interface UsageBucketRow {
41
57
  totalTokens: number;
42
58
  /** Priced portion of the cost; read `costProvenance` before labelling it. */
43
59
  costUsd: number;
60
+ /**
61
+ * Sum of the priced calls' costs, or null when no call in the row was
62
+ * priced (where `costUsd` reads 0).
63
+ */
64
+ pricedCostUsd: number | null;
44
65
  costProvenance: AggregatedCostProvenance;
45
66
  unpricedCalls: number;
67
+ /**
68
+ * Reasoning tokens reported by the row's calls. Informational, already
69
+ * inside the token buckets and `totalTokens`. Always set by `bucketUsage`.
70
+ */
71
+ reasoningTokens?: number;
46
72
  /** First and last event in the row, ms since epoch. */
47
73
  firstTimestamp: number;
48
74
  lastTimestamp: number;
@@ -53,8 +79,12 @@ export interface UsageTotals {
53
79
  tokens: TokenTotals;
54
80
  totalTokens: number;
55
81
  costUsd: number;
82
+ /** Sum of the rows' priced costs, or null when no row had a priced call. */
83
+ pricedCostUsd: number | null;
56
84
  costProvenance: AggregatedCostProvenance;
57
85
  unpricedCalls: number;
86
+ /** Sum of the rows' `reasoningTokens`. Always set by `summarizeUsageRows`. */
87
+ reasoningTokens?: number;
58
88
  }
59
89
  /** The `YYYY-MM-DD` key of the week containing `ms`, starting on `weekStartsOn`. */
60
90
  export declare function weekKey(ms: number, options?: {
@@ -65,7 +95,8 @@ export declare function weekKey(ms: number, options?: {
65
95
  export declare function usageBucketKey(event: Pick<UsageEventRecord, 'timestamp' | 'sessionId'>, options: BucketUsageOptions): string;
66
96
  /**
67
97
  * Group usage events into rows. Rows are sorted by key (sessions by first
68
- * event), then provider, project, and model, so tables read oldest first.
98
+ * event), then provider, project, and model, so tables read oldest first;
99
+ * rows grouped by `session` within one of those follow their first event.
69
100
  */
70
101
  export declare function bucketUsage(events: readonly UsageEventRecord[], options: BucketUsageOptions): UsageBucketRow[];
71
102
  /**
@@ -13,6 +13,8 @@ exports.bucketUsage = bucketUsage;
13
13
  exports.summarizeUsageRows = summarizeUsageRows;
14
14
  const costProvenance_1 = require("../aggregation/costProvenance");
15
15
  const formatting_1 = require("../formatting");
16
+ const modelInfo_1 = require("../modelInfo");
17
+ const claudeUsageDedupe_1 = require("./claudeUsageDedupe");
16
18
  function dayParts(ms, utc) {
17
19
  const date = new Date(ms);
18
20
  return utc
@@ -75,7 +77,8 @@ function addTokens(target, source) {
75
77
  }
76
78
  /**
77
79
  * Group usage events into rows. Rows are sorted by key (sessions by first
78
- * event), then provider, project, and model, so tables read oldest first.
80
+ * event), then provider, project, and model, so tables read oldest first;
81
+ * rows grouped by `session` within one of those follow their first event.
79
82
  */
80
83
  function bucketUsage(events, options) {
81
84
  const groupBy = new Set(options.groupBy ?? ['provider']);
@@ -83,13 +86,21 @@ function bucketUsage(events, options) {
83
86
  const byProvider = isSession || groupBy.has('provider');
84
87
  const byProject = isSession || groupBy.has('project');
85
88
  const byModel = groupBy.has('model');
89
+ const bySession = isSession || groupBy.has('session');
90
+ const keyModel = options.modelKey === 'canonical'
91
+ ? (model) => (model === claudeUsageDedupe_1.SYNTHETIC_MODEL ? model : (0, modelInfo_1.canonicalModelId)(model) || model)
92
+ : (model) => model;
86
93
  const buckets = new Map();
87
94
  for (const event of events) {
95
+ if (options.excludeSynthetic && event.model === claudeUsageDedupe_1.SYNTHETIC_MODEL)
96
+ continue;
88
97
  const key = usageBucketKey(event, options);
89
98
  const provider = byProvider ? event.provider : null;
90
99
  const project = byProject ? event.project : null;
91
- const model = byModel ? event.model : null;
92
- const id = [key, provider ?? '', project ?? '', model ?? ''].join('|');
100
+ const eventModel = keyModel(event.model);
101
+ const model = byModel ? eventModel : null;
102
+ const sessionId = bySession ? event.sessionId : null;
103
+ const id = JSON.stringify([key, provider, project, model, sessionId]);
93
104
  let acc = buckets.get(id);
94
105
  if (!acc) {
95
106
  acc = {
@@ -99,15 +110,17 @@ function bucketUsage(events, options) {
99
110
  provider,
100
111
  project,
101
112
  model,
102
- sessionId: isSession ? event.sessionId : null,
113
+ sessionId,
103
114
  models: [],
104
115
  calls: 0,
105
116
  sessions: 0,
106
117
  tokens: emptyTokens(),
107
118
  totalTokens: 0,
108
119
  costUsd: 0,
120
+ pricedCostUsd: null,
109
121
  costProvenance: 'none',
110
122
  unpricedCalls: 0,
123
+ reasoningTokens: 0,
111
124
  firstTimestamp: event.timestamp,
112
125
  lastTimestamp: event.timestamp,
113
126
  },
@@ -122,12 +135,14 @@ function bucketUsage(events, options) {
122
135
  row.calls += 1;
123
136
  addTokens(row.tokens, event.tokens);
124
137
  row.totalTokens += event.tokens.totalTokens;
138
+ row.reasoningTokens = (row.reasoningTokens ?? 0) + reasoningOf(event.tokens.reasoningTokens);
125
139
  row.firstTimestamp = Math.min(row.firstTimestamp, event.timestamp);
126
140
  row.lastTimestamp = Math.max(row.lastTimestamp, event.timestamp);
127
141
  acc.sessionIds.add(event.sessionId);
128
- acc.modelTokens.set(event.model, (acc.modelTokens.get(event.model) ?? 0) + event.tokens.totalTokens);
142
+ acc.modelTokens.set(eventModel, (acc.modelTokens.get(eventModel) ?? 0) + event.tokens.totalTokens);
129
143
  if (event.costUsd !== null) {
130
144
  row.costUsd += event.costUsd;
145
+ row.pricedCostUsd = (row.pricedCostUsd ?? 0) + event.costUsd;
131
146
  if (event.costProvenance === 'provider-reported')
132
147
  acc.reportedCalls += 1;
133
148
  else
@@ -153,7 +168,14 @@ function bucketUsage(events, options) {
153
168
  return rows.sort((a, b) => (isSession ? a.firstTimestamp - b.firstTimestamp : a.key.localeCompare(b.key)) ||
154
169
  (a.provider ?? '').localeCompare(b.provider ?? '') ||
155
170
  (a.project ?? '').localeCompare(b.project ?? '') ||
156
- (a.model ?? '').localeCompare(b.model ?? ''));
171
+ (a.model ?? '').localeCompare(b.model ?? '') ||
172
+ (bySession && !isSession
173
+ ? a.firstTimestamp - b.firstTimestamp ||
174
+ (a.sessionId ?? '').localeCompare(b.sessionId ?? '')
175
+ : 0));
176
+ }
177
+ function reasoningOf(value) {
178
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
157
179
  }
158
180
  /**
159
181
  * Sum rows that do not overlap (for example every row of one `bucketUsage`
@@ -167,8 +189,10 @@ function summarizeUsageRows(rows, events) {
167
189
  tokens: emptyTokens(),
168
190
  totalTokens: 0,
169
191
  costUsd: 0,
192
+ pricedCostUsd: null,
170
193
  costProvenance: 'none',
171
194
  unpricedCalls: 0,
195
+ reasoningTokens: 0,
172
196
  };
173
197
  let reportedCalls = 0;
174
198
  let estimatedCalls = 0;
@@ -178,6 +202,15 @@ function summarizeUsageRows(rows, events) {
178
202
  addTokens(totals.tokens, row.tokens);
179
203
  totals.totalTokens += row.totalTokens;
180
204
  totals.costUsd += row.costUsd;
205
+ // Rows from before `pricedCostUsd` existed: any priced call means a priced cost.
206
+ const rowPriced = row.pricedCostUsd !== undefined
207
+ ? row.pricedCostUsd
208
+ : row.calls > row.unpricedCalls
209
+ ? row.costUsd
210
+ : null;
211
+ if (rowPriced !== null)
212
+ totals.pricedCostUsd = (totals.pricedCostUsd ?? 0) + rowPriced;
213
+ totals.reasoningTokens = (totals.reasoningTokens ?? 0) + reasoningOf(row.reasoningTokens);
181
214
  totals.unpricedCalls += row.unpricedCalls;
182
215
  const priced = row.calls - row.unpricedCalls;
183
216
  if (row.costProvenance === 'reported')
@@ -7,6 +7,8 @@ import type { ZodType } from 'zod';
7
7
  export declare const DEFAULT_JSONL_TAIL_READ_BYTES: number;
8
8
  /** Default longest line parsed; longer lines are skipped and reported. */
9
9
  export declare const DEFAULT_JSONL_TAIL_MAX_LINE_BYTES: number;
10
+ /** The `version` written into, and accepted from, {@link JsonlTailCheckpoint}. */
11
+ export declare const JSONL_TAIL_CHECKPOINT_VERSION = 1;
10
12
  export interface JsonlTailBatch {
11
13
  bytesRead: number;
12
14
  eventsRead: number;
@@ -20,12 +22,68 @@ export interface JsonlTailLine {
20
22
  length: number;
21
23
  }
22
24
  export type JsonlTailResetReason = 'truncated' | 'rewritten' | 'replaced';
23
- /** The tail started over from the beginning of the file. */
25
+ /**
26
+ * What the tail does when the file it follows shrank, was rewritten, or was
27
+ * replaced: `'restart'` replays it from offset 0; `'end'` continues from its
28
+ * current end, skipping what it already holds.
29
+ */
30
+ export type JsonlTailRewritePolicy = 'restart' | 'end';
31
+ /** The tail started over from the beginning, or (with `onRewrite: 'end'`) the end, of the file. */
24
32
  export interface JsonlTailReset {
25
33
  /** `truncated`: the file shrank; `rewritten`: its first bytes or the resume point changed; `replaced`: another file now has the path. */
26
34
  reason: JsonlTailResetReason;
27
35
  /** The committed offset before the reset. */
28
36
  previousOffset: number;
37
+ /**
38
+ * Where reading continues. Present only with `onRewrite: 'end'`; with the
39
+ * default `'restart'` policy reading always continues from 0.
40
+ */
41
+ offset?: number;
42
+ }
43
+ /** An event parsed by {@link JsonlTail.read}, with its line's position. */
44
+ export interface JsonlTailEntry<T> {
45
+ event: T;
46
+ line: JsonlTailLine;
47
+ }
48
+ /** What one {@link JsonlTail.read} call read. */
49
+ export interface JsonlTailReadResult<T> extends JsonlTailBatch {
50
+ /** Events from the lines that became complete, in file order: the same ones `onEvent` received. */
51
+ events: JsonlTailEntry<T>[];
52
+ /** Present when the tail reset before reading; `onReset` received the same object. */
53
+ reset?: JsonlTailReset;
54
+ }
55
+ /**
56
+ * A JSON-serializable resume point from {@link JsonlTail.checkpoint}. It
57
+ * identifies the file by device and inode plus a digest of its first bytes,
58
+ * so a new tail (in this or a later process) can verify it is still reading
59
+ * the same, append-only file before continuing from `offset`.
60
+ */
61
+ export interface JsonlTailCheckpoint {
62
+ version: typeof JSONL_TAIL_CHECKPOINT_VERSION;
63
+ /** Resume-safe byte offset: 0, or just after a newline. */
64
+ offset: number;
65
+ /** Device and inode of the file the offset refers to; `null` before the first read. */
66
+ identity: {
67
+ dev: number;
68
+ ino: number;
69
+ } | null;
70
+ /**
71
+ * Length (at most 4096) and truncated SHA-256 hex of the file's first
72
+ * bytes when last read; `null` before the first read.
73
+ */
74
+ prefixDigest: {
75
+ bytes: number;
76
+ hash: string;
77
+ } | null;
78
+ }
79
+ /**
80
+ * Reported to `onError` (and nothing is read) when `noFollow` is set and the
81
+ * tailed path is a symbolic link.
82
+ */
83
+ export declare class JsonlTailSymlinkError extends Error {
84
+ readonly path: string;
85
+ readonly code = "JSONL_TAIL_SYMLINK";
86
+ constructor(path: string);
29
87
  }
30
88
  export interface JsonlTailOptions<T> {
31
89
  path: string;
@@ -37,20 +95,59 @@ export interface JsonlTailOptions<T> {
37
95
  onEvent: (event: T, line: JsonlTailLine) => void;
38
96
  onBatchComplete?: (batch: JsonlTailBatch) => void;
39
97
  onError?: (error: Error, line?: string) => void;
40
- /** Called after the tail resets to offset 0; events from the start follow. */
98
+ /** Called after the tail resets (see `onRewrite`); events from the new position follow. */
41
99
  onReset?: (reset: JsonlTailReset) => void;
42
100
  /** Bytes read per chunk (default 1 MiB); one poll reads chunk by chunk to the end. */
43
101
  maxReadBytes?: number;
44
102
  /** Longest line parsed (default 16 MiB); a longer line is skipped and reported to `onError`. */
45
103
  maxLineBytes?: number;
104
+ /**
105
+ * Where to continue after the file is truncated, rewritten, or replaced,
106
+ * and after `resumeFrom()` rejects a checkpoint. `'restart'` (default)
107
+ * replays from offset 0. `'end'` continues from the file's current end, or
108
+ * from the start of an unfinished final line as `startAtEnd` does. `onReset`
109
+ * fires either way.
110
+ */
111
+ onRewrite?: JsonlTailRewritePolicy;
112
+ /**
113
+ * Refuse to follow a symbolic link at `path`: the file is opened with
114
+ * `O_NOFOLLOW` where the platform defines it (a no-op where it does not,
115
+ * such as Windows). A symlinked path is reported to `onError` as a
116
+ * {@link JsonlTailSymlinkError} and not read or watched. Only the final
117
+ * path component is checked; symlinked parent directories still resolve.
118
+ */
119
+ noFollow?: boolean;
46
120
  }
47
- export interface JsonlTail {
121
+ export interface JsonlTail<T = unknown> {
48
122
  readonly isActive: boolean;
49
123
  start(): void;
50
124
  stop(): void;
51
125
  dispose(): void;
126
+ /**
127
+ * Reads every newly complete line now, synchronously, through the
128
+ * callbacks. While `read()` calls are queued or running, the call is
129
+ * deferred until the last of them settles.
130
+ */
52
131
  readNow(): void;
132
+ /**
133
+ * Reads like `readNow()`, with asynchronous I/O, and resolves with what it
134
+ * read; the callbacks fire as they do for `readNow()`. Never rejects for
135
+ * I/O or parse problems (those go to `onError`). Concurrent calls run one
136
+ * after another.
137
+ */
138
+ read(): Promise<JsonlTailReadResult<T>>;
53
139
  getOffset(): number;
54
140
  seekTo(offset: number): void;
141
+ /** A JSON-serializable resume point at the committed offset; does no I/O. */
142
+ checkpoint(): JsonlTailCheckpoint;
143
+ /**
144
+ * Validates `checkpoint` (shape, file identity, prefix digest, and that its
145
+ * offset still follows a newline) against the file now at the path. On
146
+ * success reading continues from its offset and this returns true.
147
+ * Otherwise the `onRewrite` policy picks the position (0, or the file's
148
+ * current end) and this returns false; `onReset` is not called. Either way
149
+ * the position overrides `startAtEnd` on a later `start()`.
150
+ */
151
+ resumeFrom(checkpoint: unknown): boolean;
55
152
  }
56
- export declare function createJsonlTail<T>(options: JsonlTailOptions<T>): JsonlTail;
153
+ export declare function createJsonlTail<T>(options: JsonlTailOptions<T>): JsonlTail<T>;