@agent-finops/core 0.5.9 → 0.6.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.
@@ -53,7 +53,7 @@ function totalUsageTokens(usage) {
53
53
  (usage.cacheWrite1hTokens ?? 0);
54
54
  }
55
55
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
56
- export function parseClaudeCodeTranscript(content, filePath = "", sinceMs) {
56
+ export function parseClaudeCodeTranscript(content, filePath = "", sinceMs, onDiagnostic) {
57
57
  const calls = [];
58
58
  const seen = new Set();
59
59
  const pendingPrompts = [];
@@ -63,6 +63,7 @@ export function parseClaudeCodeTranscript(content, filePath = "", sinceMs) {
63
63
  let latestActivityKey;
64
64
  let isSubagent = filePath.split(sep).includes("subagents");
65
65
  let parentSessionId;
66
+ let malformedLines = 0;
66
67
  for (const line of content.split("\n")) {
67
68
  if (!line.trim())
68
69
  continue;
@@ -71,6 +72,7 @@ export function parseClaudeCodeTranscript(content, filePath = "", sinceMs) {
71
72
  entry = JSON.parse(line);
72
73
  }
73
74
  catch {
75
+ malformedLines += 1;
74
76
  continue;
75
77
  }
76
78
  if (!isRecord(entry))
@@ -179,10 +181,13 @@ export function parseClaudeCodeTranscript(content, filePath = "", sinceMs) {
179
181
  for (const call of calls) {
180
182
  call.activity = activities.get(localActivityScopeKey(call.sessionId, call.workingDirectory, call.project));
181
183
  }
184
+ if (malformedLines > 0) {
185
+ onDiagnostic?.({ code: "malformed_jsonl", count: malformedLines });
186
+ }
182
187
  return calls;
183
188
  }
184
189
  /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
185
- export function parseCodexRollout(content, onEntry) {
190
+ export function parseCodexRollout(content, onEntry, onDiagnostic) {
186
191
  let model;
187
192
  let rootCwd;
188
193
  const toolWorkdirs = new Map();
@@ -201,6 +206,7 @@ export function parseCodexRollout(content, onEntry) {
201
206
  let toolCallCount = 0;
202
207
  let isSubagent = false;
203
208
  let parentSessionId;
209
+ let malformedLines = 0;
204
210
  for (const line of content.split("\n")) {
205
211
  if (!line.trim())
206
212
  continue;
@@ -209,6 +215,7 @@ export function parseCodexRollout(content, onEntry) {
209
215
  entry = JSON.parse(line);
210
216
  }
211
217
  catch {
218
+ malformedLines += 1;
212
219
  continue;
213
220
  }
214
221
  if (!isRecord(entry))
@@ -300,14 +307,33 @@ export function parseCodexRollout(content, onEntry) {
300
307
  // safer than charging the parent cumulative counter again. Likewise, a
301
308
  // recognized boundary with no later total_token_usage is not a financial
302
309
  // call yet.
310
+ if (malformedLines > 0) {
311
+ onDiagnostic?.({ code: "malformed_jsonl", count: malformedLines });
312
+ }
303
313
  if (!lastTotal || isSubagent && !rootTaskStarted)
304
314
  return [];
305
- const input = Math.max(0, (numberOf(lastTotal.input_tokens) ?? 0) -
306
- (numberOf(inheritedUsageBaseline?.input_tokens) ?? 0));
307
- const cached = Math.max(0, (numberOf(lastTotal.cached_input_tokens) ?? 0) -
315
+ const rawInput = numberOf(lastTotal.input_tokens);
316
+ const rawOutput = numberOf(lastTotal.output_tokens);
317
+ const rawCached = numberOf(lastTotal.cached_input_tokens);
318
+ const rawReportedTotal = numberOf(lastTotal.total_tokens);
319
+ const baselineInput = numberOf(inheritedUsageBaseline?.input_tokens);
320
+ const baselineOutput = numberOf(inheritedUsageBaseline?.output_tokens);
321
+ const baselineReportedTotal = numberOf(inheritedUsageBaseline?.total_tokens);
322
+ const currentComponentsComplete = rawInput !== undefined && rawOutput !== undefined && !((rawReportedTotal ?? 0) > 0 && rawInput === 0 && rawOutput === 0);
323
+ const baselineComponentsComplete = !inheritedUsageBaseline || (baselineInput !== undefined && baselineOutput !== undefined && !((baselineReportedTotal ?? 0) > 0 && baselineInput === 0 && baselineOutput === 0));
324
+ const usageSupport = currentComponentsComplete && baselineComponentsComplete
325
+ ? "complete"
326
+ : "unsupported_token_shape";
327
+ if (usageSupport === "unsupported_token_shape") {
328
+ onDiagnostic?.({ code: "unsupported_token_shape", count: 1 });
329
+ }
330
+ const input = Math.max(0, (rawInput ?? 0) - (baselineInput ?? 0));
331
+ const cached = Math.max(0, (rawCached ?? 0) -
308
332
  (numberOf(inheritedUsageBaseline?.cached_input_tokens) ?? 0));
309
- const output = Math.max(0, (numberOf(lastTotal.output_tokens) ?? 0) -
310
- (numberOf(inheritedUsageBaseline?.output_tokens) ?? 0));
333
+ const output = Math.max(0, (rawOutput ?? 0) - (baselineOutput ?? 0));
334
+ const reportedTotalTokens = rawReportedTotal === undefined
335
+ ? undefined
336
+ : Math.max(0, rawReportedTotal - (baselineReportedTotal ?? 0));
311
337
  const latestTurnUsage = lastTurn
312
338
  ? codexTurnUsage(lastTurn)
313
339
  : undefined;
@@ -333,6 +359,8 @@ export function parseCodexRollout(content, onEntry) {
333
359
  activity,
334
360
  latestTurnUsage,
335
361
  usageScope: "session_cumulative",
362
+ usageSupport,
363
+ ...(reportedTotalTokens !== undefined ? { reportedTotalTokens } : {}),
336
364
  usage: {
337
365
  // Codex input_tokens INCLUDES cached tokens; split them out.
338
366
  inputTokens: Math.max(0, input - cached),
@@ -417,24 +445,51 @@ export async function loadLocalAgentUsage(options = {}) {
417
445
  let filesParsed = 0;
418
446
  const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
419
447
  const sinceMs = typeof since === "number" && Number.isFinite(since) ? since : undefined;
420
- for (const file of await listJsonlFiles(claudeDir)) {
421
- const content = await readFile(file, "utf8").catch(() => "");
448
+ const diagnostics = [];
449
+ const sourceScans = [
450
+ emptySourceScan("claude-code"),
451
+ emptySourceScan("codex")
452
+ ];
453
+ const claudeScan = sourceScans[0];
454
+ const codexScan = sourceScans[1];
455
+ for (const file of await listJsonlFiles(claudeDir, claudeScan, diagnostics)) {
456
+ let content;
457
+ try {
458
+ content = await readFile(file, "utf8");
459
+ }
460
+ catch (error) {
461
+ recordUnreadableFile("claude-code", claudeScan, diagnostics, error);
462
+ continue;
463
+ }
422
464
  if (!content)
423
465
  continue;
424
466
  filesParsed += 1;
425
- calls.push(...parseClaudeCodeTranscript(content, file, sinceMs));
467
+ claudeScan.filesParsed += 1;
468
+ calls.push(...parseClaudeCodeTranscript(content, file, sinceMs, (diagnostic) => {
469
+ recordParseDiagnostic("claude-code", claudeScan, diagnostics, diagnostic);
470
+ }));
426
471
  }
427
- for (const file of await listJsonlFiles(codexDir)) {
472
+ for (const file of await listJsonlFiles(codexDir, codexScan, diagnostics)) {
428
473
  if (!basename(file).startsWith("rollout-"))
429
474
  continue;
430
- const content = await readFile(file, "utf8").catch(() => "");
475
+ let content;
476
+ try {
477
+ content = await readFile(file, "utf8");
478
+ }
479
+ catch (error) {
480
+ recordUnreadableFile("codex", codexScan, diagnostics, error);
481
+ continue;
482
+ }
431
483
  if (!content)
432
484
  continue;
433
485
  filesParsed += 1;
486
+ codexScan.filesParsed += 1;
434
487
  const collector = codexInvocationFiles
435
488
  ? createCodexInvocationCollector(sinceMs)
436
489
  : undefined;
437
- calls.push(...parseCodexRollout(content, collector?.consume));
490
+ calls.push(...parseCodexRollout(content, collector?.consume, (diagnostic) => {
491
+ recordParseDiagnostic("codex", codexScan, diagnostics, diagnostic);
492
+ }));
438
493
  if (collector)
439
494
  codexInvocationFiles.push(collector.finish());
440
495
  }
@@ -447,6 +502,8 @@ export async function loadLocalAgentUsage(options = {}) {
447
502
  calls: filtered,
448
503
  filesParsed,
449
504
  agentsDetected: [...new Set(filtered.map((call) => call.agent))],
505
+ sourceScans,
506
+ diagnostics,
450
507
  ...(codexInvocationFiles ? { codexInvocationFiles } : {})
451
508
  };
452
509
  }
@@ -468,8 +525,9 @@ export function aggregateCalls(calls) {
468
525
  cacheWrite5mTokens: sum(groupCalls, (c) => c.usage.cacheWrite5mTokens ?? 0),
469
526
  cacheWrite1hTokens: sum(groupCalls, (c) => c.usage.cacheWrite1hTokens ?? 0)
470
527
  };
471
- const amountUsd = estimateTokenCostUsd(model, usage);
472
- const priced = typeof amountUsd === "number";
528
+ const usageSupported = groupCalls.every((call) => call.usageSupport !== "unsupported_token_shape");
529
+ const amountUsd = usageSupported ? estimateTokenCostUsd(model, usage) : undefined;
530
+ const priced = usageSupported && typeof amountUsd === "number";
473
531
  records.push({
474
532
  id: slug(["local", agent, day, model, project].join("-")),
475
533
  timestamp: new Date(`${day}T00:00:00Z`).toISOString(),
@@ -498,25 +556,131 @@ export function aggregateCalls(calls) {
498
556
  }
499
557
  return records.sort((left, right) => left.id.localeCompare(right.id));
500
558
  }
501
- async function listJsonlFiles(root) {
502
- const exists = await stat(root).then((s) => s.isDirectory()).catch(() => false);
503
- if (!exists)
559
+ async function listJsonlFiles(root, scan, diagnostics) {
560
+ let rootStat;
561
+ try {
562
+ rootStat = await stat(root);
563
+ }
564
+ catch (error) {
565
+ if (isNodeError(error, "ENOENT")) {
566
+ scan.directoryStatus = "missing";
567
+ diagnostics.push({
568
+ agent: scan.agent,
569
+ code: "directory_missing",
570
+ severity: "info",
571
+ message: `${agentLabel(scan.agent)} transcript directory was not found.`,
572
+ count: 1
573
+ });
574
+ }
575
+ else {
576
+ scan.directoryStatus = "unreadable";
577
+ diagnostics.push({
578
+ agent: scan.agent,
579
+ code: "directory_unreadable",
580
+ severity: "error",
581
+ message: `${agentLabel(scan.agent)} transcript directory could not be read${errorCodeSuffix(error)}.`,
582
+ count: 1
583
+ });
584
+ }
504
585
  return [];
586
+ }
587
+ if (!rootStat.isDirectory()) {
588
+ scan.directoryStatus = "unreadable";
589
+ diagnostics.push({
590
+ agent: scan.agent,
591
+ code: "directory_unreadable",
592
+ severity: "error",
593
+ message: `${agentLabel(scan.agent)} transcript path is not a readable directory.`,
594
+ count: 1
595
+ });
596
+ return [];
597
+ }
598
+ scan.directoryStatus = "readable";
505
599
  const out = [];
506
600
  const queue = [root];
507
601
  while (queue.length > 0) {
508
602
  const dir = queue.pop();
509
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
603
+ let entries;
604
+ try {
605
+ entries = await readdir(dir, { withFileTypes: true });
606
+ }
607
+ catch (error) {
608
+ scan.directoryStatus = "unreadable";
609
+ diagnostics.push({
610
+ agent: scan.agent,
611
+ code: "directory_unreadable",
612
+ severity: "error",
613
+ message: `${agentLabel(scan.agent)} transcript directory could not be read${errorCodeSuffix(error)}.`,
614
+ count: 1
615
+ });
616
+ continue;
617
+ }
510
618
  for (const entry of entries) {
511
619
  const path = join(dir, entry.name);
512
620
  if (entry.isDirectory())
513
621
  queue.push(path);
514
- else if (entry.isFile() && entry.name.endsWith(".jsonl"))
622
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
515
623
  out.push(path);
624
+ scan.filesDiscovered += 1;
625
+ }
516
626
  }
517
627
  }
518
628
  return out;
519
629
  }
630
+ function emptySourceScan(agent) {
631
+ return {
632
+ agent,
633
+ directoryStatus: "readable",
634
+ filesDiscovered: 0,
635
+ filesParsed: 0,
636
+ malformedLines: 0,
637
+ unreadableFiles: 0,
638
+ unsupportedUsageSnapshots: 0
639
+ };
640
+ }
641
+ function recordUnreadableFile(agent, scan, diagnostics, error) {
642
+ scan.unreadableFiles += 1;
643
+ diagnostics.push({
644
+ agent,
645
+ code: "file_unreadable",
646
+ severity: "error",
647
+ message: `${agentLabel(agent)} transcript file could not be read${errorCodeSuffix(error)}.`,
648
+ count: 1
649
+ });
650
+ }
651
+ function recordParseDiagnostic(agent, scan, diagnostics, diagnostic) {
652
+ if (diagnostic.code === "malformed_jsonl") {
653
+ scan.malformedLines += diagnostic.count;
654
+ diagnostics.push({
655
+ agent,
656
+ code: diagnostic.code,
657
+ severity: "warning",
658
+ message: `${diagnostic.count} malformed JSONL line(s) were skipped in ${agentLabel(agent)} transcripts.`,
659
+ count: diagnostic.count
660
+ });
661
+ return;
662
+ }
663
+ scan.unsupportedUsageSnapshots += diagnostic.count;
664
+ diagnostics.push({
665
+ agent,
666
+ code: diagnostic.code,
667
+ severity: "warning",
668
+ message: `${diagnostic.count} ${agentLabel(agent)} token snapshot(s) lacked the input/output components required for pricing.`,
669
+ count: diagnostic.count
670
+ });
671
+ }
672
+ function agentLabel(agent) {
673
+ return agent === "claude-code" ? "Claude Code" : "Codex";
674
+ }
675
+ function errorCodeSuffix(error) {
676
+ const code = error instanceof Error
677
+ ? error.code
678
+ : undefined;
679
+ return code && /^[A-Z0-9_]+$/.test(code) ? ` (${code})` : "";
680
+ }
681
+ function isNodeError(error, code) {
682
+ return error instanceof Error && error.code === code;
683
+ }
520
684
  function projectFromCwd(cwd) {
521
685
  if (!cwd)
522
686
  return undefined;
@@ -52,7 +52,6 @@ const pricingRules = [
52
52
  // Open-weight models with NO canonical price (llama, qwen, mistral, glm):
53
53
  // hosting rates vary several-fold by provider, so we deliberately return
54
54
  // undefined -> costConfidence "missing" instead of inventing a number.
55
- { match: /codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 }
56
55
  ];
57
56
  export function findPricingRule(model) {
58
57
  return pricingRules.find((rule) => rule.match.test(model));
@@ -8,6 +8,7 @@ type ProviderResponse = {
8
8
  get: (name: string) => string | null;
9
9
  };
10
10
  json: () => Promise<unknown>;
11
+ text?: () => Promise<string>;
11
12
  };
12
13
  export type ProviderQaPagination = {
13
14
  label: string;
@@ -73,9 +74,9 @@ export type CreateProviderConnectionInput = {
73
74
  sourceId?: string;
74
75
  authReference: string;
75
76
  verifiedRecordCount: number;
76
- totalUsd: number;
77
+ totalUsd: number | null;
77
78
  fetchedAt?: Date;
78
- /** Record-derived completeness; the source's verification label mirrors it. */
79
+ /** Record-derived completeness; this controls financial evidence, not connector validation. */
79
80
  completeness?: ProviderConnectorResult["completeness"];
80
81
  };
81
82
  export type TokenResolver = (reference: string) => string;