@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/README.md +38 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +28 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +28 -3
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +26 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +207 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +36 -3
- package/src/i18n/es.ts +36 -3
- package/src/i18n/types.ts +26 -0
- package/src/index.ts +271 -55
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,
|
|
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
|
-
* ±
|
|
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 =
|
|
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
|
-
//
|
|
501
|
-
//
|
|
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;
|