@trazum/cli 1.9.0 → 1.10.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { join, resolve as resolvePath } from 'node:path';
4
- import { BASELINE_FILENAME, BASELINE_VERSION, MAX_BASELINE_BYTES, breaches, compareToBaseline, formatBaseline, moneyIsComparable, parseBaseline, DEFAULT_USAGE, LOCALES, reviewAgeDays, RULES, comparePrompts, countTokensAnthropic, getModel, applyRewrites, computeSavings, profilePrompt, toPromptfoo, PHRASE_LANGUAGES, estimateTokens, formatUsd, formatSignedUsd, getMessages, nearestName, optimize, toOtlpMetrics, providerFromEnv, reorderForCache, sharedPrefixes, cacheableMinimum, findExamples, plannedCalls, pruneExamples, extractPrompts, promptId, hasMarker, SOURCE_EXTENSIONS, detectFromSource, evaluate, refineWithLlm, rejectionText, suggestRewrites, reviewExamples, withExactTokenCounts, } from '@trazum/core';
4
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, cacheHitRate, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
5
5
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
6
6
  // Everything that reads the filesystem, on its own entry point so the web
7
7
  // bundle cannot reach it. See packages/core/src/node.ts.
@@ -241,6 +241,7 @@ const COMMAND_FLAGS = {
241
241
  ],
242
242
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
243
243
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
244
+ profile: ['json', 'pricing', 'pricing-live'],
244
245
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
245
246
  prune: ['cases', 'concurrency', 'json', 'yes'],
246
247
  diff: ['level', 'model', 'calls', 'output-tokens', 'batch', 'max-growth', 'optimized', 'markdown-out', 'all', 'prompt'],
@@ -330,7 +331,7 @@ function renderDiff(before, after, t) {
330
331
  * The provider's name when the estimator was not calibrated for it.
331
332
  *
332
333
  * `estimateTokens` is a heuristic tuned against Claude's tokenizer, and the
333
- * ±15% band descends from that. Printing the same band beside a GPT or Kimi
334
+ * ±10% band descends from that. Printing the same band beside a GPT or Kimi
334
335
  * figure states a precision nobody has measured for that family — and since the
335
336
  * catalogue grew past Anthropic, that is most of it. Returns null when the model
336
337
  * is Anthropic's, where the band is at least the claim it was written for.
@@ -353,11 +354,81 @@ function languageNames(codes, t) {
353
354
  return names[0] ?? '';
354
355
  return `${names.slice(0, -1).join(', ')} ${t.languages.and} ${names[names.length - 1]}`;
355
356
  }
357
+ /**
358
+ * Advisories whose entire pitch is money.
359
+ *
360
+ * On a subscription these are not weaker advice, they are not advice: "use a
361
+ * cheaper model" saves nothing on a flat plan, and its detail text quotes dollars
362
+ * per month, so suppressing only the price tag beside the title left the money in
363
+ * the sentence underneath.
364
+ */
365
+ const MONEY_ONLY_ADVISORIES = new Set([
366
+ 'model-downgrade',
367
+ 'batch-api',
368
+ 'output-dominated',
369
+ 'promo-pricing',
370
+ 'prompt-caching-not-worth-it',
371
+ ]);
372
+ /**
373
+ * The one thing worth doing about this prompt, and how it compares to shortening it.
374
+ *
375
+ * `null` when there is nothing to say: no advisory carries a figure, or the
376
+ * reader is on a subscription where a monthly saving is meaningless. A heading
377
+ * with a shrug under it is worse than no heading.
378
+ */
379
+ function biggestLever(result, tokensOnly, t) {
380
+ /**
381
+ * One guard, and it is the only thing deciding.
382
+ *
383
+ * The first version also filtered the candidate list by `!tokensOnly`, which
384
+ * duplicated this and made it untestable: removing the guard left the filter
385
+ * still suppressing the line, so a mutation that priced a subscription passed
386
+ * the suite. Two checks for one condition is one check and one place for a bug.
387
+ */
388
+ if (tokensOnly)
389
+ return null;
390
+ const best = result.advisories.find((a) => (a.estimatedMonthlyUsd ?? 0) > 0);
391
+ if (!best?.estimatedMonthlyUsd)
392
+ return null;
393
+ const ruleSaving = result.savings.monthlySavingsUsd;
394
+ return {
395
+ line: t.report.biggestLeverDetail(best.title, formatUsd(best.estimatedMonthlyUsd),
396
+ // The multiple is the point of the line, and it is only honest when there
397
+ // is something to divide by. A prompt the rules could not improve at all
398
+ // gets the amount and no ratio rather than a division by zero dressed up.
399
+ ruleSaving > 0 ? Math.round(best.estimatedMonthlyUsd / ruleSaving) : null),
400
+ };
401
+ }
356
402
  function printReport(result, showDiff, t, examplesReview = null, reorder = null, tokensOnly = false, host = { id: 'terminal', displayName: 'terminal', billing: 'unknown', evidence: null }, suggestions = null) {
357
403
  const n = (value) => value.toLocaleString(t.numberLocale);
358
404
  const sourceNote = result.tokenSource === 'heuristic'
359
405
  ? c.dim(t.report.estimated(offFamilyName(result.usage.model)))
360
406
  : c.dim(t.report.exactCount());
407
+ /**
408
+ * The largest lever, first.
409
+ *
410
+ * This line used to be the last thing in the report and it is the most useful
411
+ * thing in it. Measured on an ordinary support prompt — already reasonably
412
+ * written, which is what a real one is — the rules recover **three tokens of
413
+ * 306**, worth $0.75 a month, while the cache reorder sitting below them is
414
+ * worth $48. The report opened with the 1.3% and closed with the 64×.
415
+ *
416
+ * That ordering is not a presentation quibble. It teaches the reader that
417
+ * shortening the prompt is what this tool is for, and on any prompt somebody
418
+ * competent wrote, shortening it is the smallest thing available. The rules
419
+ * earn their keep on genuine bloat — a duplicated paragraph, "due to the fact
420
+ * that" — and recover close to nothing once that is gone, because they recover
421
+ * waste rather than creating savings.
422
+ *
423
+ * So the answer to "what should I do about this prompt" goes at the top, and
424
+ * the token count follows as the detail it is.
425
+ */
426
+ const best = biggestLever(result, tokensOnly, t);
427
+ if (best) {
428
+ console.log();
429
+ console.log(c.bold(t.report.biggestLever()));
430
+ console.log(` ${c.dim(wrap(best.line, 74, ' '))}`);
431
+ }
361
432
  console.log();
362
433
  console.log(c.bold(t.report.inputTokens()));
363
434
  console.log(` ${n(result.tokensBefore)} → ${c.green(n(result.tokensAfter))} ${c.bold(`-${result.reductionPct.toFixed(1)}%`)}${sourceNote}`);
@@ -460,13 +531,7 @@ function printReport(result, showDiff, t, examplesReview = null, reorder = null,
460
531
  // The rest stay: an overflowing context window still fails the call, a
461
532
  // contradiction is still wrong, redundant examples still cost tokens, and
462
533
  // caching still buys latency and rate-limit headroom.
463
- const MONEY_ONLY = new Set([
464
- 'model-downgrade',
465
- 'batch-api',
466
- 'output-dominated',
467
- 'promo-pricing',
468
- 'prompt-caching-not-worth-it',
469
- ]);
534
+ const MONEY_ONLY = MONEY_ONLY_ADVISORIES;
470
535
  const advisories = tokensOnly
471
536
  ? result.advisories.filter((a) => !MONEY_ONLY.has(a.id))
472
537
  : result.advisories;
@@ -497,19 +562,8 @@ function printReport(result, showDiff, t, examplesReview = null, reorder = null,
497
562
  console.log(` ${marker} ${column}${c.bold(advisory.title)}`);
498
563
  console.log(`${gutter}${c.dim(wrap(advisory.detail, 78 - gutter.length, gutter))}`);
499
564
  }
500
- // What to do first. The rules trimmed $1.25 and the top advisory is worth
501
- // $506; leaving the reader to notice that by comparing four numbers in four
502
- // sentences is how the most valuable line in the report gets skipped.
503
- const best = advisories.find((a) => (a.estimatedMonthlyUsd ?? 0) > 0);
504
- if (!tokensOnly && best?.estimatedMonthlyUsd) {
505
- const ruleSaving = result.savings.monthlySavingsUsd;
506
- const line = t.report.biggestLeverDetail(best.title, formatUsd(best.estimatedMonthlyUsd), ruleSaving > 0 ? Math.round(best.estimatedMonthlyUsd / ruleSaving) : null);
507
- console.log();
508
- // Wrapped to the same width as everything else. An unwrapped closing line
509
- // is the one that runs off a narrow terminal, and it is the line most
510
- // worth reading.
511
- console.log(` ${c.bold(t.report.biggestLever())} ${c.dim(wrap(line, 62, ' '))}`);
512
- }
565
+ // The "start here" line is printed at the top of the report now, where a
566
+ // reader who stops after four lines still sees it.
513
567
  }
514
568
  printSuggestions(suggestions, t, n);
515
569
  printRest(result, showDiff, t, examplesReview, n);
@@ -1304,6 +1358,134 @@ function monthlyCostOf(tokens, usage, pricing) {
1304
1358
  function isoDate() {
1305
1359
  return new Date().toISOString().slice(0, 10);
1306
1360
  }
1361
+ /**
1362
+ * `trazum profile <log.jsonl>` — where the money actually went.
1363
+ *
1364
+ * Every other command in this file reads a prompt and reasons forward about what
1365
+ * it would cost. This one reads what the provider charged and reasons backward,
1366
+ * and it exists because the forward direction can only see the smallest line item:
1367
+ * on an ordinary support prompt the rules recover about 1% of the monthly figure
1368
+ * while output alone was 87% of it.
1369
+ *
1370
+ * **Money is never suppressed here, unlike every other report.** The rest of the
1371
+ * CLI hides dollar figures on a subscription host, because a saving quoted to
1372
+ * somebody on a flat plan is money that does not exist. This log is a record of
1373
+ * metered API calls somebody was actually billed for — the bill exists wherever
1374
+ * Trazum happens to be running, so the host has no bearing on it.
1375
+ */
1376
+ async function commandProfile(args, pricing, t) {
1377
+ const path = args.positional[0];
1378
+ if (path === undefined) {
1379
+ console.log();
1380
+ console.log(c.dim(wrap(t.profile.noTarget(), 74, ' ')));
1381
+ console.log();
1382
+ return;
1383
+ }
1384
+ const raw = await readFile(path, 'utf8');
1385
+ const report = profileUsage(raw, { catalogue: pricing });
1386
+ const n = (value) => value.toLocaleString(t.numberLocale);
1387
+ const pct = (share) => `${(share * 100).toFixed(1)}%`;
1388
+ if (boolFlag(args, 'json')) {
1389
+ console.log(JSON.stringify(report, null, 2));
1390
+ return;
1391
+ }
1392
+ /**
1393
+ * Nothing priced means there is no report, not a report of zero.
1394
+ *
1395
+ * The guard was `total.calls === 0 && unpriced.calls === 0`, so a log whose every
1396
+ * model was unknown fell through and printed a full report built from a zeroed
1397
+ * total: `0 calls · $0`, four `$0 / 0.0%` rows, a meaningless "Input is 0.0% of
1398
+ * this bill", and — on a log containing a hundred thousand cache-read tokens —
1399
+ * the flatly false "Caching was never used on these calls".
1400
+ *
1401
+ * Two affirmatively wrong claims and a $0 headline for a real bill. The trailing
1402
+ * unpriced note was the only correct line on screen, and it was the quietest.
1403
+ */
1404
+ if (report.total.calls === 0) {
1405
+ console.log();
1406
+ console.log(c.dim(report.unpriced.calls === 0 ? t.profile.empty() : t.profile.nothingPriced()));
1407
+ reportProfileGaps(report, t, n);
1408
+ return;
1409
+ }
1410
+ const shares = sharesOf(report.total);
1411
+ const parts = [
1412
+ [t.profile.partInput(), report.total.inputUsd, shares.input, report.total.inputTokens],
1413
+ [t.profile.partCacheRead(), report.total.cacheReadUsd, shares.cacheRead, report.total.cacheReadTokens],
1414
+ [t.profile.partCacheWrite(), report.total.cacheWriteUsd, shares.cacheWrite, report.total.cacheWriteTokens],
1415
+ [t.profile.partOutput(), report.total.outputUsd, shares.output, report.total.outputTokens],
1416
+ ];
1417
+ console.log();
1418
+ console.log(c.bold(t.profile.heading()));
1419
+ console.log(` ${t.profile.spent(n(report.total.calls), formatUsd(report.total.totalUsd))}`);
1420
+ console.log();
1421
+ // Every part, including the zero ones. A row missing because it was zero reads
1422
+ // as a row somebody forgot, and "you are not caching at all" is a finding.
1423
+ for (const [name, usd, share, tokens] of parts) {
1424
+ console.log(` ${c.dim(t.profile.part(name, formatUsd(usd), pct(share), n(tokens)))}`);
1425
+ }
1426
+ /**
1427
+ * The line the command exists for: which part of the bill to argue with.
1428
+ *
1429
+ * When output is both the biggest part and over half, the two sentences say the
1430
+ * same thing and the second says more — so only the second prints. Reporting a
1431
+ * fact twice in adjacent lines reads as a bug, and it was one.
1432
+ */
1433
+ const [biggestName, , biggestShare] = parts.reduce((a, b) => (b[1] > a[1] ? b : a));
1434
+ const outputDominates = shares.output > 0.5;
1435
+ console.log();
1436
+ if (outputDominates) {
1437
+ console.log(` ${c.bold(wrap(t.profile.outputDominates(pct(shares.output)), 74, ' '))}`);
1438
+ }
1439
+ else {
1440
+ console.log(` ${c.bold(t.profile.biggestPart(biggestName, pct(biggestShare)))}`);
1441
+ }
1442
+ const hitRate = cacheHitRate(report.total);
1443
+ console.log(hitRate === null
1444
+ ? ` ${c.dim(wrap(t.profile.cacheNever(), 74, ' '))}`
1445
+ : ` ${c.dim(t.profile.cacheHit(pct(hitRate)))}`);
1446
+ /**
1447
+ * A total that assumed a cache-write rate is a floor, and says so.
1448
+ *
1449
+ * Anthropic's 1-hour entry costs 2x input against the 5-minute entry's 1.25x. A
1450
+ * log carrying only the flat `cache_creation_input_tokens` cannot say which, so
1451
+ * the cheaper one is used — and the flattering direction is exactly the one this
1452
+ * tool refuses to take quietly.
1453
+ */
1454
+ if (report.total.assumedWriteTtlCalls > 0) {
1455
+ console.log(` ${c.dim(wrap(t.profile.assumedWriteTtl(report.total.assumedWriteTtlCalls), 74, ' '))}`);
1456
+ }
1457
+ for (const [heading, rows] of [
1458
+ [t.profile.byLabelHeading(), report.byLabel.map((r) => [r.label === UNLABELLED ? t.profile.unlabelled() : r.label, r.breakdown])],
1459
+ [t.profile.byModelHeading(), report.byModel.map((r) => [r.model, r.breakdown])],
1460
+ ]) {
1461
+ if (rows.length <= 1)
1462
+ continue; // One row is the total again, said twice.
1463
+ console.log();
1464
+ console.log(c.bold(heading));
1465
+ for (const [name, breakdown] of rows) {
1466
+ const share = report.total.totalUsd > 0 ? breakdown.totalUsd / report.total.totalUsd : 0;
1467
+ console.log(` ${t.profile.row(name, formatUsd(breakdown.totalUsd), pct(share), n(breakdown.calls))}`);
1468
+ }
1469
+ }
1470
+ reportProfileGaps(report, t, n);
1471
+ }
1472
+ /**
1473
+ * What the profile could not account for, said out loud.
1474
+ *
1475
+ * Separated so both the empty and the populated path print it. A total that
1476
+ * silently omits calls is wrong in the flattering direction, which is the fault
1477
+ * this repository keeps finding in itself.
1478
+ */
1479
+ function reportProfileGaps(report, t, n) {
1480
+ if (report.unpricedModels.length > 0) {
1481
+ console.log();
1482
+ console.log(` ${c.yellow('!')} ${c.dim(wrap(t.profile.unpriced(report.unpricedModels.join(', '), report.unpriced.calls), 74, ' '))}`);
1483
+ }
1484
+ if (report.skippedLines.length > 0) {
1485
+ const shown = report.skippedLines.slice(0, 5).join(', ');
1486
+ console.log(` ${c.dim(t.profile.skipped(report.skippedLines.length, report.skippedLines.length > 5 ? `${shown}…` : shown))}`);
1487
+ }
1488
+ }
1307
1489
  /**
1308
1490
  * `trazum baseline <dir>` — record what the estate costs now.
1309
1491
  *
@@ -2401,6 +2583,9 @@ async function main() {
2401
2583
  case 'baseline':
2402
2584
  await commandBaseline(args, config, pricing, t, locale);
2403
2585
  break;
2586
+ case 'profile':
2587
+ await commandProfile(args, pricing, t);
2588
+ break;
2404
2589
  case 'eval':
2405
2590
  await commandEval(args, config, t, locale);
2406
2591
  break;