@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/README.md +12 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +60 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +62 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +49 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +311 -26
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +85 -0
- package/src/i18n/es.ts +87 -0
- package/src/i18n/types.ts +49 -0
- package/src/index.ts +381 -31
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(
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|