@trazum/cli 1.36.0 → 1.38.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
@@ -11,6 +11,7 @@ import {
11
11
  cacheableMinimum,
12
12
  analyzeCachePrefix,
13
13
  billLevers,
14
+ buildPlan,
14
15
  cacheEconomics,
15
16
  cacheHitRate,
16
17
  contextPressure,
@@ -23,6 +24,8 @@ import {
23
24
  coverageDrift,
24
25
  driversBetween,
25
26
  explainGateFailure,
27
+ assignSources,
28
+ fleetRollup,
26
29
  labelCoverage,
27
30
  measuredUsage,
28
31
  gateMargin,
@@ -74,6 +77,7 @@ import {
74
77
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
75
78
  import { dayOf, formatGap, median, spanDays } from './time.js';
76
79
  import type {
80
+ FleetSource,
77
81
  MeasuredUsage,
78
82
  BaselineBreach,
79
83
  BaselineChange,
@@ -169,6 +173,7 @@ interface Args {
169
173
  const VALUE_FLAGS = new Set([
170
174
  'against',
171
175
  'from-log',
176
+ 'min-usd',
172
177
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
173
178
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
174
179
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -309,6 +314,13 @@ function levelFlag(args: Args, config: TrazumConfig, t: CliMessages): RuleLevel
309
314
  * model id. It beats the default because reading the code is better than
310
315
  * assuming, and loses to config because being told is better than reading.
311
316
  */
317
+ /**
318
+ * The file names a usage log answers to, shared by every command that reads a
319
+ * directory of them. One list, because two commands disagreeing on what counts
320
+ * as a log would be the same directory billing differently by verb.
321
+ */
322
+ const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
323
+
312
324
  /**
313
325
  * One usage log, gzip included, shared by every command that reads one.
314
326
  *
@@ -461,7 +473,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
461
473
  ],
462
474
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
463
475
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
464
- 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'],
476
+ 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'],
477
+ plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
465
478
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
466
479
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
467
480
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2201,6 +2214,140 @@ function isoDate(): string {
2201
2214
  * metered API calls somebody was actually billed for — the bill exists wherever
2202
2215
  * Trazum happens to be running, so the host has no bearing on it.
2203
2216
  */
2217
+ /**
2218
+ * `trazum plan <log>` — not a list of findings, a ranked plan of what to do.
2219
+ *
2220
+ * The composition (route and batch on one slice never summed) happens in
2221
+ * core's `buildPlan`; this command owns the I/O and the rendering. The plan
2222
+ * saves as a dated JSON file on request, which is what makes verifying it
2223
+ * against a later log possible at all — a prediction nobody wrote down is a
2224
+ * prediction nobody can be held to.
2225
+ */
2226
+ async function commandPlan(
2227
+ args: Args,
2228
+ pricing: PricingCatalogue,
2229
+ t: CliMessages,
2230
+ ): Promise<void> {
2231
+ const path = args.positional[0];
2232
+ if (path === undefined) throw new Error(t.plan.noTarget());
2233
+
2234
+ const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
2235
+ const READABLE = [...LOG_EXTENSIONS, ...GZ];
2236
+ const target = await stat(path).catch(() => null);
2237
+ let files: string[] = [path];
2238
+ if (target?.isDirectory()) {
2239
+ const entries = await readdir(path, { withFileTypes: true });
2240
+ files = entries
2241
+ .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2242
+ .map((entry) => join(path, entry.name))
2243
+ .sort((a, b) => a.localeCompare(b));
2244
+ if (files.length === 0) throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
2245
+ }
2246
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
2247
+ const raw = texts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2248
+
2249
+ const report = profileUsage(raw, { catalogue: pricing });
2250
+ if (report.total.calls === 0) throw new Error(t.plan.nothingPriced());
2251
+ const levers = billLevers(report, { catalogue: pricing });
2252
+ const plan = buildPlan(report, levers, pricing.lastReviewed);
2253
+
2254
+ const minUsd = typeof args.flags.get('min-usd') === 'string' ? numberFlag(args, 'min-usd', 0, t) : 0;
2255
+ const actions = plan.actions.filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) >= minUsd);
2256
+ const filtered = plan.actions.length - actions.length;
2257
+ const droppedUsd = plan.actions
2258
+ .filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) < minUsd)
2259
+ .reduce((sum, a) => sum + (a.savingUsd ?? a.stakeUsd ?? 0), 0);
2260
+
2261
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2262
+ /**
2263
+ * The document's totals cover the actions the document holds — a filtered
2264
+ * plan whose totals still counted the filtered actions would be a file
2265
+ * that contradicts itself, and 1.39's verify would hold it to money it
2266
+ * cannot see. What --min-usd dropped is stated with its worth, never
2267
+ * silently.
2268
+ */
2269
+ const stamped = {
2270
+ ...plan,
2271
+ actions,
2272
+ projectedSavingUsd: actions.reduce((sum, a) => sum + (a.savingUsd ?? 0), 0),
2273
+ measuredStakeUsd: actions.reduce((sum, a) => sum + (a.stakeUsd ?? 0), 0),
2274
+ createdAt: new Date().toISOString(),
2275
+ };
2276
+
2277
+ const outPath = stringFlag(args, 'out');
2278
+ if (outPath !== undefined) {
2279
+ await writeFile(outPath, `${JSON.stringify(stamped, null, 2)}\n`);
2280
+ }
2281
+
2282
+ await writeMarkdown(args, () => {
2283
+ const lines: string[] = [];
2284
+ lines.push(`## ${t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))}`);
2285
+ lines.push('');
2286
+ lines.push(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)));
2287
+ if (plan.span === null) {
2288
+ lines.push('');
2289
+ lines.push(`_${t.plan.noClock()}_`);
2290
+ }
2291
+ for (const action of actions) {
2292
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
2293
+ const money =
2294
+ action.savingUsd !== null
2295
+ ? t.plan.projected(formatUsd(action.savingUsd))
2296
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
2297
+ lines.push('');
2298
+ lines.push(`### ${t.plan.action(action.kind, name, action.model)} — ${money}`);
2299
+ if (action.detail.routeTo !== undefined) lines.push(`- ${t.plan.routeTo(action.detail.routeTo.displayName)}`);
2300
+ for (const assumption of action.assumes) lines.push(`- ${t.plan.assume(assumption)}`);
2301
+ if (action.check !== null) lines.push(`- ${t.plan.check(action.check)}`);
2302
+ }
2303
+ if (filtered > 0) {
2304
+ lines.push('');
2305
+ lines.push(`_${t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd))}_`);
2306
+ }
2307
+ lines.push('');
2308
+ lines.push(`_${t.plan.footer()}_`);
2309
+ return lines.join('\n');
2310
+ });
2311
+
2312
+ if (boolFlag(args, 'json')) {
2313
+ console.log(JSON.stringify(stamped, null, 2));
2314
+ return;
2315
+ }
2316
+
2317
+ console.log(c.bold(t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))));
2318
+ console.log(
2319
+ ` ${wrap(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)), 74, ' ')}`,
2320
+ );
2321
+ if (plan.span === null) {
2322
+ console.log(` ${c.dim(wrap(t.plan.noClock(), 74, ' '))}`);
2323
+ }
2324
+ for (const action of actions) {
2325
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
2326
+ const money =
2327
+ action.savingUsd !== null
2328
+ ? t.plan.projected(formatUsd(action.savingUsd))
2329
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
2330
+ console.log();
2331
+ console.log(` ${c.green('→')} ${c.bold(t.plan.action(action.kind, name, action.model))} ${money}`);
2332
+ if (action.detail.routeTo !== undefined) {
2333
+ console.log(` ${c.dim(t.plan.routeTo(action.detail.routeTo.displayName))}`);
2334
+ }
2335
+ for (const assumption of action.assumes) {
2336
+ console.log(` ${c.yellow('?')} ${c.dim(wrap(t.plan.assume(assumption), 72, ' '))}`);
2337
+ }
2338
+ if (action.check !== null) {
2339
+ console.log(` ${c.dim(wrap(t.plan.check(action.check), 72, ' '))}`);
2340
+ }
2341
+ }
2342
+ if (filtered > 0) {
2343
+ console.log();
2344
+ console.log(` ${c.dim(wrap(t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd)), 74, ' '))}`);
2345
+ }
2346
+ console.log();
2347
+ console.log(` ${c.dim(wrap(t.plan.footer(), 74, ' '))}`);
2348
+ if (outPath !== undefined) console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
2349
+ }
2350
+
2204
2351
  async function commandProfile(args: Args, config: TrazumConfig, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
2205
2352
  const path = args.positional[0];
2206
2353
  if (path === undefined) {
@@ -2224,7 +2371,6 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2224
2371
  * directory holding nothing readable is an error naming what it looked for,
2225
2372
  * not an empty report.
2226
2373
  */
2227
- const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
2228
2374
  /**
2229
2375
  * The same names, gzipped — which is what a rotated log actually looks like
2230
2376
  * a day after it rotates.
@@ -2241,10 +2387,17 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2241
2387
  const target = await stat(path).catch(() => null);
2242
2388
  let logFiles: string[] = [path];
2243
2389
  if (target?.isDirectory()) {
2244
- const entries = await readdir(path, { withFileTypes: true });
2390
+ /**
2391
+ * Recursive under `--by-source`, flat otherwise. The fleet's whole point
2392
+ * is one directory per service, so the walk must descend; the flat mode
2393
+ * keeps its long-standing behaviour because a directory of rotated logs
2394
+ * with an unrelated subfolder should not quietly absorb it.
2395
+ */
2396
+ const bySourceMode = boolFlag(args, 'by-source');
2397
+ const entries = await readdir(path, { withFileTypes: true, recursive: bySourceMode });
2245
2398
  logFiles = entries
2246
2399
  .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2247
- .map((entry) => join(path, entry.name))
2400
+ .map((entry) => join(entry.parentPath ?? path, entry.name))
2248
2401
  .sort((a, b) => a.localeCompare(b));
2249
2402
  if (logFiles.length === 0) {
2250
2403
  throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
@@ -2266,6 +2419,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2266
2419
  // A file that does not end in a newline would otherwise glue its last record
2267
2420
  // to the next file's first one, and both would be reported as unreadable.
2268
2421
  const raw = logTexts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2422
+
2269
2423
  /**
2270
2424
  * The drill-down. A label that matches nothing is an error naming the labels
2271
2425
  * that exist — the route command's rule, for the route command's reason: a
@@ -2368,6 +2522,123 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2368
2522
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2369
2523
  const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
2370
2524
 
2525
+ /**
2526
+ * `--by-source`: one report per service, plus the rollup — the fleet.
2527
+ *
2528
+ * A merged bill is right for one service and wrong for twelve: it hides
2529
+ * which service the money comes from, per-service budgets cannot exist,
2530
+ * and the findings a comparison between services could make are invisible.
2531
+ * Files are assigned to sources by the most specific matching glob from the
2532
+ * config's `sources` block; a file matching no source is named loudly,
2533
+ * because a log that silently joined no report is spend missing from every
2534
+ * bill.
2535
+ */
2536
+ if (boolFlag(args, 'by-source')) {
2537
+ const sourceDefs = config.sources;
2538
+ if (sourceDefs === undefined || Object.keys(sourceDefs).length === 0) {
2539
+ throw new Error(t.profile.bySourceNeedsConfig());
2540
+ }
2541
+ const { bySource, unmatched } = assignSources(logFiles, sourceDefs);
2542
+ if (bySource.size === 0) {
2543
+ throw new Error(t.profile.bySourceNothingMatched(Object.keys(sourceDefs).join(', ')));
2544
+ }
2545
+
2546
+ const textByFile = new Map(logFiles.map((file, i) => [file, logTexts[i]!]));
2547
+ const fleetSources: FleetSource[] = [];
2548
+ const cacheDeltas = new Map<string, number>();
2549
+ for (const [name, files] of [...bySource.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2550
+ const text = files
2551
+ .map((file) => textByFile.get(file)!)
2552
+ .map((chunk) => (chunk.endsWith('\n') ? chunk : `${chunk}\n`))
2553
+ .join('');
2554
+ const sourceReport = profileUsage(text, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
2555
+ fleetSources.push({ name, report: sourceReport });
2556
+ cacheDeltas.set(name, cacheEconomics(sourceReport.total).deltaUsd);
2557
+ }
2558
+ const aggregate = profileUsage(raw, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
2559
+ const rollup = fleetRollup(fleetSources, {
2560
+ cacheDeltas,
2561
+ aggregateCacheDelta: cacheEconomics(aggregate.total).deltaUsd,
2562
+ });
2563
+
2564
+ if (boolFlag(args, 'json')) {
2565
+ console.log(
2566
+ JSON.stringify(
2567
+ {
2568
+ schemaVersion: 1,
2569
+ bySource: fleetSources.map((source) => ({ name: source.name, report: source.report })),
2570
+ rollup: {
2571
+ totalUsd: rollup.totalUsd,
2572
+ calls: rollup.calls,
2573
+ sources: rollup.sources,
2574
+ worst: rollup.worst,
2575
+ mismatchedSpans: rollup.mismatchedSpans,
2576
+ splitBrains: rollup.splitBrains,
2577
+ cacheUnderwater: rollup.cacheUnderwater,
2578
+ unmatchedFiles: unmatched,
2579
+ },
2580
+ },
2581
+ (key, value) => (value instanceof Map ? undefined : value),
2582
+ 2,
2583
+ ),
2584
+ );
2585
+ } else {
2586
+ console.log(c.bold(t.profile.fleetHeading(n(rollup.sources.length), formatUsd(rollup.totalUsd), t.profile.calls(rollup.calls))));
2587
+ for (const row of rollup.sources) {
2588
+ const span = row.spanDays === null ? t.profile.fleetNoClock() : t.profile.fleetSpan(row.spanDays.toFixed(1));
2589
+ console.log(
2590
+ ` ${t.profile.fleetRow(row.name, formatUsd(row.usd), pct(row.share), t.profile.calls(row.calls), span)}`,
2591
+ );
2592
+ }
2593
+ if (rollup.worst !== null && rollup.sources.length > 1) {
2594
+ console.log();
2595
+ console.log(` ${c.yellow('!')} ${c.bold(wrap(t.profile.fleetWorst(rollup.worst.name, formatUsd(rollup.worst.usd), pct(rollup.worst.share)), 74, ' '))}`);
2596
+ }
2597
+ if (rollup.mismatchedSpans) {
2598
+ console.log(` ${c.dim(wrap(t.profile.fleetMismatchedSpans(), 74, ' '))}`);
2599
+ }
2600
+ for (const split of rollup.splitBrains.slice(0, 3)) {
2601
+ console.log();
2602
+ console.log(
2603
+ ` ${c.yellow('!')} ${wrap(t.profile.fleetSplitBrain(split.label, split.sources.map((v) => `${v.name} → ${v.model} (${formatUsd(v.usd)})`).join(', ')), 74, ' ')}`,
2604
+ );
2605
+ }
2606
+ for (const under of rollup.cacheUnderwater.slice(0, 3)) {
2607
+ console.log(
2608
+ ` ${c.yellow('!')} ${wrap(t.profile.fleetCacheUnderwater(under.name, formatUsd(under.deltaUsd)), 74, ' ')}`,
2609
+ );
2610
+ }
2611
+ for (const file of unmatched) {
2612
+ console.log(` ${c.yellow('!')} ${wrap(t.profile.fleetUnmatched(file), 74, ' ')}`);
2613
+ }
2614
+ console.log();
2615
+ console.log(` ${c.dim(wrap(t.profile.fleetFooter(), 74, ' '))}`);
2616
+ }
2617
+
2618
+ /**
2619
+ * The per-source gates. Each budget judges its own service and the run
2620
+ * fails naming the service — a total that hides which source crossed its
2621
+ * line is the rendering this mode exists to end. Waivable per source
2622
+ * through `bySource:<name>`, under the same expiry discipline.
2623
+ */
2624
+ const bySourceBudgets = config.spend?.bySource ?? {};
2625
+ for (const [name, limit] of Object.entries(bySourceBudgets)) {
2626
+ const found = fleetSources.find((source) => source.name === name);
2627
+ if (found === undefined) {
2628
+ console.error(c.dim(t.profile.fleetBudgetMissing(name)));
2629
+ continue;
2630
+ }
2631
+ const usd = found.report.total.totalUsd;
2632
+ if (usd > limit) {
2633
+ console.error(c.red(t.profile.fleetBudgetFailed(name, formatUsd(usd), formatUsd(limit))));
2634
+ process.exitCode = 1;
2635
+ } else {
2636
+ console.error(c.dim(t.profile.fleetBudgetOk(name, formatUsd(usd), formatUsd(limit))));
2637
+ }
2638
+ }
2639
+ return;
2640
+ }
2641
+
2371
2642
  /**
2372
2643
  * `--dry-run`: what this log could and could not answer, and no bill.
2373
2644
  *
@@ -5887,6 +6158,9 @@ async function main(): Promise<void> {
5887
6158
  case 'profile':
5888
6159
  await commandProfile(args, config, pricing, t);
5889
6160
  break;
6161
+ case 'plan':
6162
+ await commandPlan(args, pricing, t);
6163
+ break;
5890
6164
  case 'route':
5891
6165
  await commandRoute(args, pricing, t);
5892
6166
  break;