@trazum/cli 1.35.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  coverageDrift,
24
24
  driversBetween,
25
25
  explainGateFailure,
26
+ assignSources,
27
+ fleetRollup,
28
+ labelCoverage,
29
+ measuredUsage,
26
30
  gateMargin,
27
31
  GATE_MARGIN_TIGHT,
28
32
  estimateTokens,
@@ -72,6 +76,8 @@ import {
72
76
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
73
77
  import { dayOf, formatGap, median, spanDays } from './time.js';
74
78
  import type {
79
+ FleetSource,
80
+ MeasuredUsage,
75
81
  BaselineBreach,
76
82
  BaselineChange,
77
83
  BaselineComparison,
@@ -165,6 +171,7 @@ interface Args {
165
171
 
166
172
  const VALUE_FLAGS = new Set([
167
173
  'against',
174
+ 'from-log',
168
175
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
169
176
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
170
177
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -305,6 +312,23 @@ function levelFlag(args: Args, config: TrazumConfig, t: CliMessages): RuleLevel
305
312
  * model id. It beats the default because reading the code is better than
306
313
  * assuming, and loses to config because being told is better than reading.
307
314
  */
315
+ /**
316
+ * One usage log, gzip included, shared by every command that reads one.
317
+ *
318
+ * A `.gz` that will not decompress is an error naming the file — skipping it
319
+ * would be a figure quietly missing a day, the failure this repository
320
+ * refuses everywhere it can occur.
321
+ */
322
+ async function readUsageLog(file: string, t: CliMessages): Promise<string> {
323
+ if (!file.endsWith('.gz')) return readFile(file, 'utf8');
324
+ const compressed = await readFile(file);
325
+ try {
326
+ return gunzipSync(compressed).toString('utf8');
327
+ } catch (error) {
328
+ throw new Error(t.profile.badGzip(file, error instanceof Error ? error.message : String(error)));
329
+ }
330
+ }
331
+
308
332
  function usageFrom(
309
333
  args: Args,
310
334
  config: TrazumConfig,
@@ -436,11 +460,11 @@ const COMMAND_FLAGS: Record<string, string[]> = {
436
460
  'level', 'model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch',
437
461
  'disable', 'llm', 'exact-tokens', 'diff', 'reorder', 'out', 'o',
438
462
  'tokens-only', 'cost', 'prompt', 'suggest', 'apply-suggestions',
439
- 'cache-suggestions',
463
+ 'cache-suggestions', 'from-log', 'label', 'all-labels',
440
464
  ],
441
465
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
442
466
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
443
- profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary'],
467
+ profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
444
468
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
445
469
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
446
470
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -629,6 +653,8 @@ function printReport(
629
653
  suggestions: { result: SuggestResult; applied: boolean; locale: Locale } | null = null,
630
654
  /** They named a scenario, and the host is suppressing the money anyway. */
631
655
  namedScenario = false,
656
+ /** Present when the usage came from a log rather than from typing. */
657
+ measured: MeasuredUsage | null = null,
632
658
  ): void {
633
659
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
634
660
  const sourceNote =
@@ -776,7 +802,7 @@ function printReport(
776
802
  if (tokensOnly) {
777
803
  printTokensOnly(result, host, t, n, namedScenario);
778
804
  } else {
779
- printMoney(result, t, n);
805
+ printMoney(result, t, n, measured);
780
806
  }
781
807
 
782
808
  // On a subscription, an advisory whose entire pitch is money is not weaker
@@ -852,17 +878,61 @@ function printReport(
852
878
  }
853
879
 
854
880
  /** The cost section, for anyone billed by the token. */
855
- function printMoney(result: OptimizationResult, t: CliMessages, n: (v: number) => string): void {
881
+ function printMoney(
882
+ result: OptimizationResult,
883
+ t: CliMessages,
884
+ n: (v: number) => string,
885
+ /** Present when the usage came from a log rather than from typing. */
886
+ measured: MeasuredUsage | null = null,
887
+ ): void {
856
888
  const { savings } = result;
857
889
  console.log();
858
890
  console.log(c.bold(t.report.costWith(savings.modelDisplayName)));
859
- console.log(
860
- ` ${t.report.usageLine(
861
- n(result.usage.callsPerMonth),
862
- result.usage.avgOutputTokens,
863
- result.usage.batchEligible,
864
- )}`,
865
- );
891
+ /**
892
+ * The usage line names its provenance. "1,000 calls/month" typed and
893
+ * "1,043 calls measured over 12 days, scaled" are different claims about
894
+ * the same multiplication, and the reader budgeting on the result must
895
+ * know which one they are holding. Under the week floor nothing is scaled
896
+ * and nothing says "month": the figures cover exactly the period measured.
897
+ */
898
+ if (measured !== null) {
899
+ if (measured.scaled !== null) {
900
+ console.log(
901
+ ` ${t.report.usageLineMeasured(
902
+ n(measured.calls),
903
+ measured.scaled.fromDays.toFixed(1),
904
+ n(result.usage.callsPerMonth),
905
+ result.usage.avgOutputTokens,
906
+ result.usage.batchEligible,
907
+ )}`,
908
+ );
909
+ } else {
910
+ console.log(
911
+ ` ${t.report.usageLineMeasuredPeriod(
912
+ n(measured.calls),
913
+ measured.spanDays === null ? null : measured.spanDays.toFixed(1),
914
+ result.usage.avgOutputTokens,
915
+ result.usage.batchEligible,
916
+ )}`,
917
+ );
918
+ }
919
+ if (measured.models.count > 1) {
920
+ console.log(
921
+ ` ${c.dim(wrap(t.report.measuredModelShare(measured.models.chosen, `${(measured.models.chosenShareOfSpend * 100).toFixed(0)}%`, n(measured.models.count)), 74, ' '))}`,
922
+ );
923
+ }
924
+ if (measured.outputUnmeasured) {
925
+ console.log(` ${c.dim(wrap(t.report.measuredNoOutput(), 74, ' '))}`);
926
+ }
927
+ } else {
928
+ console.log(
929
+ ` ${t.report.usageLine(
930
+ n(result.usage.callsPerMonth),
931
+ result.usage.avgOutputTokens,
932
+ result.usage.batchEligible,
933
+ )}`,
934
+ );
935
+ }
866
936
  // Said, not assumed. Once prices can be overlaid locally, a figure from the
867
937
  // bundled catalogue and a figure from somebody's JSON file look identical, and
868
938
  // the reader has to be able to tell which one they are about to budget against.
@@ -875,17 +945,27 @@ function printMoney(result: OptimizationResult, t: CliMessages, n: (v: number) =
875
945
  ` ${c.yellow(t.report.pricingOverlaid(touched.join(', '), result.pricingSource.lastReviewed))}`,
876
946
  );
877
947
  }
948
+ const periodOnly = measured !== null && measured.scaled === null;
878
949
  console.log(
879
950
  ` ${formatUsd(savings.perMonth.before.totalUsd)} → ` +
880
951
  `${c.green(formatUsd(savings.perMonth.after.totalUsd))} ` +
881
952
  c.bold(
882
- t.report.perMonthSaving(
883
- formatUsd(savings.monthlySavingsUsd),
884
- savings.monthlySavingsPct.toFixed(1),
885
- ),
953
+ periodOnly
954
+ ? t.report.perPeriodSaving(
955
+ formatUsd(savings.monthlySavingsUsd),
956
+ savings.monthlySavingsPct.toFixed(1),
957
+ )
958
+ : t.report.perMonthSaving(
959
+ formatUsd(savings.monthlySavingsUsd),
960
+ savings.monthlySavingsPct.toFixed(1),
961
+ ),
886
962
  ),
887
963
  );
888
-
964
+ if (periodOnly) {
965
+ console.log(
966
+ ` ${c.dim(wrap(t.report.periodNotScaled(measured!.spanDays === null ? null : measured!.spanDays.toFixed(1)), 74, ' '))}`,
967
+ );
968
+ }
889
969
  }
890
970
 
891
971
  /**
@@ -1372,6 +1452,97 @@ async function commandOptimize(
1372
1452
  t: CliMessages,
1373
1453
  locale: Locale,
1374
1454
  ): Promise<void> {
1455
+ /**
1456
+ * `--all-labels`: every mapped prompt against its own measured traffic,
1457
+ * ranked by what the change is worth — the list a person actually wants,
1458
+ * which is "which prompt do I edit first".
1459
+ *
1460
+ * Requires `--from-log`, because ranking estimated savings that were all
1461
+ * multiplied by the same typed guess ranks the prompts by length, and calls
1462
+ * that a priority. And it renders both coverage mismatches at the end: a
1463
+ * prompt mapped to a label with no traffic is dead weight or a rename, and
1464
+ * a label carrying real money with no prompt mapped is the workload nobody
1465
+ * can optimise because nobody said where it lives.
1466
+ */
1467
+ if (boolFlag(args, 'all-labels')) {
1468
+ const fromLogPath = stringFlag(args, 'from-log');
1469
+ if (fromLogPath === undefined) throw new Error(t.errors.allLabelsNeedsLog());
1470
+ const labelsMap = config.labels ?? {};
1471
+ if (Object.keys(labelsMap).length === 0) throw new Error(t.errors.allLabelsNeedsMap());
1472
+ const report = profileUsage(await readUsageLog(fromLogPath, t), { catalogue: pricing });
1473
+ const coverage = labelCoverage(report, labelsMap);
1474
+ const level = levelFlag(args, config, t);
1475
+
1476
+ interface Row {
1477
+ label: string;
1478
+ path: string;
1479
+ tokensBefore: number;
1480
+ tokensAfter: number;
1481
+ savingUsd: number;
1482
+ periodOnly: boolean;
1483
+ spentUsd: number;
1484
+ }
1485
+ const rows: Row[] = [];
1486
+ const unreadable: { label: string; path: string }[] = [];
1487
+ for (const { label, promptPath } of coverage.joined) {
1488
+ const m = measuredUsage(report, label, { batchEligible: config.usage?.batchEligible ?? false });
1489
+ if (m === null) continue;
1490
+ let text: string;
1491
+ try {
1492
+ text = await readFile(promptPath, 'utf8');
1493
+ } catch {
1494
+ unreadable.push({ label, path: promptPath });
1495
+ continue;
1496
+ }
1497
+ const r = optimize(text, { level, usage: m.profile, locale, pricing });
1498
+ rows.push({
1499
+ label,
1500
+ path: promptPath,
1501
+ tokensBefore: r.tokensBefore,
1502
+ tokensAfter: r.tokensAfter,
1503
+ savingUsd: r.savings.monthlySavingsUsd,
1504
+ periodOnly: m.scaled === null,
1505
+ spentUsd: m.spentUsd,
1506
+ });
1507
+ }
1508
+ rows.sort((a, b) => b.savingUsd - a.savingUsd);
1509
+
1510
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
1511
+ console.log(c.bold(t.report.allLabelsHeading(n(rows.length))));
1512
+ for (const row of rows) {
1513
+ const saving = row.periodOnly
1514
+ ? t.report.allLabelsRowPeriod(formatUsd(row.savingUsd))
1515
+ : t.report.allLabelsRow(formatUsd(row.savingUsd));
1516
+ console.log(
1517
+ ` ${row.savingUsd > 0 ? c.green('→') : c.dim('·')} ${c.bold(row.label)} ${saving} ${c.dim(`${row.path} · ${n(row.tokensBefore)} → ${n(row.tokensAfter)} tokens · ${formatUsd(row.spentUsd)} measured`)}`,
1518
+ );
1519
+ }
1520
+ if (rows.length > 0) {
1521
+ console.log(` ${c.dim(wrap(t.report.allLabelsFooter(), 74, ' '))}`);
1522
+ }
1523
+
1524
+ /**
1525
+ * The mismatches, both directions, never silently. These are the two
1526
+ * failures neither side can see alone.
1527
+ */
1528
+ for (const gap of coverage.trafficWithoutPrompt.slice(0, 5)) {
1529
+ console.log(
1530
+ ` ${c.yellow('!')} ${wrap(t.report.allLabelsUnmapped(gap.label, formatUsd(gap.spentUsd)), 74, ' ')}`,
1531
+ );
1532
+ }
1533
+ for (const dead of coverage.mappedWithoutTraffic) {
1534
+ console.log(
1535
+ ` ${c.dim(wrap(t.report.allLabelsDead(dead.label, dead.promptPath), 74, ' '))}`,
1536
+ );
1537
+ }
1538
+ for (const miss of unreadable) {
1539
+ console.log(
1540
+ ` ${c.yellow('!')} ${wrap(t.report.allLabelsUnreadable(miss.label, miss.path), 74, ' ')}`,
1541
+ );
1542
+ }
1543
+ return;
1544
+ }
1545
+
1375
1546
  const target = args.positional[0];
1376
1547
  const raw = await readInput(target, t);
1377
1548
  const level = levelFlag(args, config, t);
@@ -1393,7 +1564,61 @@ async function commandOptimize(
1393
1564
  // Detection sits between config and defaults, as everywhere: a flag beats
1394
1565
  // config, config beats what the code says, and what the code says beats a
1395
1566
  // built-in default that has no idea which provider you use.
1396
- const usage = usageFrom(args, config, t, source?.model);
1567
+ let usage = usageFrom(args, config, t, source?.model);
1568
+
1569
+ /**
1570
+ * `--from-log`: the multiplication stops guessing.
1571
+ *
1572
+ * The saving printed below is `token delta × usage`, and until now every
1573
+ * part of `usage` was typed by a human. A usage log knows the real call
1574
+ * count, the real output size, the real cache share and the model the
1575
+ * calls actually went to — so `--from-log` measures them, and the typed
1576
+ * flags are refused beside it rather than merged: measuring and typing the
1577
+ * same figure is a contradiction, not a preference order.
1578
+ */
1579
+ const fromLog = stringFlag(args, 'from-log');
1580
+ let measured: MeasuredUsage | null = null;
1581
+ if (fromLog !== undefined) {
1582
+ for (const flag of ['calls', 'output-tokens', 'cache-hit-rate', 'model']) {
1583
+ if (args.flags.get(flag) !== undefined) {
1584
+ throw new Error(t.errors.fromLogConflict(flag));
1585
+ }
1586
+ }
1587
+ const report = profileUsage(await readUsageLog(fromLog, t), { catalogue: pricing });
1588
+
1589
+ /**
1590
+ * Which label this prompt is. `--label` says it outright; otherwise the
1591
+ * config's `labels` map is read in reverse — it maps labels to prompt
1592
+ * files, and the file on the command line is looked up among its values.
1593
+ * Ambiguity (two labels mapped to one file) is an error naming both,
1594
+ * never a silent first match.
1595
+ */
1596
+ let label = stringFlag(args, 'label');
1597
+ if (label === undefined && target !== undefined && config.labels !== undefined) {
1598
+ const hits = Object.entries(config.labels)
1599
+ .filter(([, path]) => resolvePath(path) === resolvePath(target))
1600
+ .map(([name]) => name);
1601
+ if (hits.length > 1) throw new Error(t.errors.fromLogAmbiguousLabel(target, hits.join(', ')));
1602
+ label = hits[0];
1603
+ }
1604
+ if (label === undefined) {
1605
+ const available = report.byLabel
1606
+ .map((row) => (row.label === UNLABELLED ? t.profile.unlabelled() : row.label))
1607
+ .join(', ');
1608
+ throw new Error(t.errors.fromLogNeedsLabel(available || '—'));
1609
+ }
1610
+
1611
+ measured = measuredUsage(report, label, {
1612
+ batchEligible: boolFlag(args, 'batch', config.usage?.batchEligible ?? false),
1613
+ });
1614
+ if (measured === null) {
1615
+ const available = report.byLabel
1616
+ .map((row) => (row.label === UNLABELLED ? t.profile.unlabelled() : row.label))
1617
+ .join(', ');
1618
+ throw new Error(t.errors.fromLogLabelEmpty(label, available || '—'));
1619
+ }
1620
+ usage = measured.profile;
1621
+ }
1397
1622
 
1398
1623
  const disableRules = disabledRules(args, config) ?? [];
1399
1624
  for (const id of disableRules) {
@@ -1586,7 +1811,15 @@ async function commandOptimize(
1586
1811
  // Cursor wants the dollars, and they should not have to leave the editor to
1587
1812
  // see them.
1588
1813
  const host = detectHost();
1589
- const tokensOnly = boolFlag(args, 'cost')
1814
+ /**
1815
+ * `--from-log` implies `--cost`, and the reasoning is different from the
1816
+ * `--calls` case documented below: `--calls` is a typed scenario parameter,
1817
+ * but a usage log with billed token counts is *evidence* — proof this
1818
+ * prompt's traffic goes to a metered API, whatever the terminal running
1819
+ * the command bills like. Withholding the money there would suppress
1820
+ * exactly the figures the person measured in order to see.
1821
+ */
1822
+ const tokensOnly = boolFlag(args, 'cost') || measured !== null
1590
1823
  ? false
1591
1824
  : boolFlag(args, 'tokens-only') || host.billing === 'subscription';
1592
1825
  /**
@@ -1609,6 +1842,7 @@ async function commandOptimize(
1609
1842
  ? { result: suggestions, applied: boolFlag(args, 'apply-suggestions'), locale }
1610
1843
  : null,
1611
1844
  namedScenario,
1845
+ measured,
1612
1846
  );
1613
1847
  if (outPath) {
1614
1848
  console.log(c.dim(t.report.wroteTo(outPath)));
@@ -2010,10 +2244,17 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2010
2244
  const target = await stat(path).catch(() => null);
2011
2245
  let logFiles: string[] = [path];
2012
2246
  if (target?.isDirectory()) {
2013
- const entries = await readdir(path, { withFileTypes: true });
2247
+ /**
2248
+ * Recursive under `--by-source`, flat otherwise. The fleet's whole point
2249
+ * is one directory per service, so the walk must descend; the flat mode
2250
+ * keeps its long-standing behaviour because a directory of rotated logs
2251
+ * with an unrelated subfolder should not quietly absorb it.
2252
+ */
2253
+ const bySourceMode = boolFlag(args, 'by-source');
2254
+ const entries = await readdir(path, { withFileTypes: true, recursive: bySourceMode });
2014
2255
  logFiles = entries
2015
2256
  .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2016
- .map((entry) => join(path, entry.name))
2257
+ .map((entry) => join(entry.parentPath ?? path, entry.name))
2017
2258
  .sort((a, b) => a.localeCompare(b));
2018
2259
  if (logFiles.length === 0) {
2019
2260
  throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
@@ -2031,19 +2272,11 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2031
2272
  * alternative — skipping it — is a total quietly missing a day, which is
2032
2273
  * the failure this repository refuses in every other place it can occur.
2033
2274
  */
2034
- const readLog = async (file: string): Promise<string> => {
2035
- if (!file.endsWith('.gz')) return readFile(file, 'utf8');
2036
- const compressed = await readFile(file);
2037
- try {
2038
- return gunzipSync(compressed).toString('utf8');
2039
- } catch (error) {
2040
- throw new Error(t.profile.badGzip(file, error instanceof Error ? error.message : String(error)));
2041
- }
2042
- };
2043
- const logTexts = await Promise.all(logFiles.map((file) => readLog(file)));
2275
+ const logTexts = await Promise.all(logFiles.map((file) => readUsageLog(file, t)));
2044
2276
  // A file that does not end in a newline would otherwise glue its last record
2045
2277
  // to the next file's first one, and both would be reported as unreadable.
2046
2278
  const raw = logTexts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2279
+
2047
2280
  /**
2048
2281
  * The drill-down. A label that matches nothing is an error naming the labels
2049
2282
  * that exist — the route command's rule, for the route command's reason: a
@@ -2146,6 +2379,123 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2146
2379
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2147
2380
  const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
2148
2381
 
2382
+ /**
2383
+ * `--by-source`: one report per service, plus the rollup — the fleet.
2384
+ *
2385
+ * A merged bill is right for one service and wrong for twelve: it hides
2386
+ * which service the money comes from, per-service budgets cannot exist,
2387
+ * and the findings a comparison between services could make are invisible.
2388
+ * Files are assigned to sources by the most specific matching glob from the
2389
+ * config's `sources` block; a file matching no source is named loudly,
2390
+ * because a log that silently joined no report is spend missing from every
2391
+ * bill.
2392
+ */
2393
+ if (boolFlag(args, 'by-source')) {
2394
+ const sourceDefs = config.sources;
2395
+ if (sourceDefs === undefined || Object.keys(sourceDefs).length === 0) {
2396
+ throw new Error(t.profile.bySourceNeedsConfig());
2397
+ }
2398
+ const { bySource, unmatched } = assignSources(logFiles, sourceDefs);
2399
+ if (bySource.size === 0) {
2400
+ throw new Error(t.profile.bySourceNothingMatched(Object.keys(sourceDefs).join(', ')));
2401
+ }
2402
+
2403
+ const textByFile = new Map(logFiles.map((file, i) => [file, logTexts[i]!]));
2404
+ const fleetSources: FleetSource[] = [];
2405
+ const cacheDeltas = new Map<string, number>();
2406
+ for (const [name, files] of [...bySource.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2407
+ const text = files
2408
+ .map((file) => textByFile.get(file)!)
2409
+ .map((chunk) => (chunk.endsWith('\n') ? chunk : `${chunk}\n`))
2410
+ .join('');
2411
+ const sourceReport = profileUsage(text, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
2412
+ fleetSources.push({ name, report: sourceReport });
2413
+ cacheDeltas.set(name, cacheEconomics(sourceReport.total).deltaUsd);
2414
+ }
2415
+ const aggregate = profileUsage(raw, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
2416
+ const rollup = fleetRollup(fleetSources, {
2417
+ cacheDeltas,
2418
+ aggregateCacheDelta: cacheEconomics(aggregate.total).deltaUsd,
2419
+ });
2420
+
2421
+ if (boolFlag(args, 'json')) {
2422
+ console.log(
2423
+ JSON.stringify(
2424
+ {
2425
+ schemaVersion: 1,
2426
+ bySource: fleetSources.map((source) => ({ name: source.name, report: source.report })),
2427
+ rollup: {
2428
+ totalUsd: rollup.totalUsd,
2429
+ calls: rollup.calls,
2430
+ sources: rollup.sources,
2431
+ worst: rollup.worst,
2432
+ mismatchedSpans: rollup.mismatchedSpans,
2433
+ splitBrains: rollup.splitBrains,
2434
+ cacheUnderwater: rollup.cacheUnderwater,
2435
+ unmatchedFiles: unmatched,
2436
+ },
2437
+ },
2438
+ (key, value) => (value instanceof Map ? undefined : value),
2439
+ 2,
2440
+ ),
2441
+ );
2442
+ } else {
2443
+ console.log(c.bold(t.profile.fleetHeading(n(rollup.sources.length), formatUsd(rollup.totalUsd), t.profile.calls(rollup.calls))));
2444
+ for (const row of rollup.sources) {
2445
+ const span = row.spanDays === null ? t.profile.fleetNoClock() : t.profile.fleetSpan(row.spanDays.toFixed(1));
2446
+ console.log(
2447
+ ` ${t.profile.fleetRow(row.name, formatUsd(row.usd), pct(row.share), t.profile.calls(row.calls), span)}`,
2448
+ );
2449
+ }
2450
+ if (rollup.worst !== null && rollup.sources.length > 1) {
2451
+ console.log();
2452
+ console.log(` ${c.yellow('!')} ${c.bold(wrap(t.profile.fleetWorst(rollup.worst.name, formatUsd(rollup.worst.usd), pct(rollup.worst.share)), 74, ' '))}`);
2453
+ }
2454
+ if (rollup.mismatchedSpans) {
2455
+ console.log(` ${c.dim(wrap(t.profile.fleetMismatchedSpans(), 74, ' '))}`);
2456
+ }
2457
+ for (const split of rollup.splitBrains.slice(0, 3)) {
2458
+ console.log();
2459
+ console.log(
2460
+ ` ${c.yellow('!')} ${wrap(t.profile.fleetSplitBrain(split.label, split.sources.map((v) => `${v.name} → ${v.model} (${formatUsd(v.usd)})`).join(', ')), 74, ' ')}`,
2461
+ );
2462
+ }
2463
+ for (const under of rollup.cacheUnderwater.slice(0, 3)) {
2464
+ console.log(
2465
+ ` ${c.yellow('!')} ${wrap(t.profile.fleetCacheUnderwater(under.name, formatUsd(under.deltaUsd)), 74, ' ')}`,
2466
+ );
2467
+ }
2468
+ for (const file of unmatched) {
2469
+ console.log(` ${c.yellow('!')} ${wrap(t.profile.fleetUnmatched(file), 74, ' ')}`);
2470
+ }
2471
+ console.log();
2472
+ console.log(` ${c.dim(wrap(t.profile.fleetFooter(), 74, ' '))}`);
2473
+ }
2474
+
2475
+ /**
2476
+ * The per-source gates. Each budget judges its own service and the run
2477
+ * fails naming the service — a total that hides which source crossed its
2478
+ * line is the rendering this mode exists to end. Waivable per source
2479
+ * through `bySource:<name>`, under the same expiry discipline.
2480
+ */
2481
+ const bySourceBudgets = config.spend?.bySource ?? {};
2482
+ for (const [name, limit] of Object.entries(bySourceBudgets)) {
2483
+ const found = fleetSources.find((source) => source.name === name);
2484
+ if (found === undefined) {
2485
+ console.error(c.dim(t.profile.fleetBudgetMissing(name)));
2486
+ continue;
2487
+ }
2488
+ const usd = found.report.total.totalUsd;
2489
+ if (usd > limit) {
2490
+ console.error(c.red(t.profile.fleetBudgetFailed(name, formatUsd(usd), formatUsd(limit))));
2491
+ process.exitCode = 1;
2492
+ } else {
2493
+ console.error(c.dim(t.profile.fleetBudgetOk(name, formatUsd(usd), formatUsd(limit))));
2494
+ }
2495
+ }
2496
+ return;
2497
+ }
2498
+
2149
2499
  /**
2150
2500
  * `--dry-run`: what this log could and could not answer, and no bill.
2151
2501
  *
@@ -2206,7 +2556,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2206
2556
  // The same reader as the log itself, so `--against last-month.jsonl.gz`
2207
2557
  // works: a comparison that could only read one of the two formats would
2208
2558
  // be a flag that fails on exactly the rotated file it exists to read.
2209
- ? profileUsage(await readLog(againstPath), {
2559
+ ? profileUsage(await readUsageLog(againstPath, t), {
2210
2560
  catalogue: pricing,
2211
2561
  label: onlyLabel,
2212
2562
  // The same window on both sides, for the same reason as the label: