@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/src/i18n/en.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CliMessages } from './types.js';
|
|
2
2
|
|
|
3
|
+
/** Counts, grouped. A log with forty thousand torn lines should say so legibly. */
|
|
4
|
+
const count = (value: number): string => value.toLocaleString('en-US');
|
|
5
|
+
|
|
3
6
|
/** "(46 days ago)", or nothing when the age is unknown. */
|
|
4
7
|
const ago = (days: number | null): string =>
|
|
5
8
|
days === null ? '' : days === 0 ? ' (today)' : days === 1 ? ' (1 day ago)' : ` (${days} days ago)`;
|
|
@@ -371,7 +374,7 @@ ${bold('EXAMPLES')}
|
|
|
371
374
|
inputTokens: () => 'Input tokens',
|
|
372
375
|
estimated: (offFamily) =>
|
|
373
376
|
offFamily === null
|
|
374
|
-
? ' (estimated, ±
|
|
377
|
+
? ' (estimated, ±10%)'
|
|
375
378
|
: ` (estimated — the counter is calibrated on Claude, not ${offFamily})`,
|
|
376
379
|
exactCount: () => ' (exact count)',
|
|
377
380
|
rulesApplied: () => 'Rules applied',
|
|
@@ -551,7 +554,7 @@ ${bold('EXAMPLES')}
|
|
|
551
554
|
`Showing the most recent ${shown}. Pass --limit for more.`,
|
|
552
555
|
followedRename: (from) => `Followed a rename: earlier revisions are ${from}.`,
|
|
553
556
|
estimateNote: () =>
|
|
554
|
-
'Token counts are estimates (±
|
|
557
|
+
'Token counts are estimates (±10%). The trend is the point; the absolute figures are not.',
|
|
555
558
|
},
|
|
556
559
|
|
|
557
560
|
languages: {
|
|
@@ -754,7 +757,7 @@ ${bold('EXAMPLES')}
|
|
|
754
757
|
footer: (source, level) => `Token counts ${source} · rule level \`${level}\``,
|
|
755
758
|
pricingOverlaid: (count, lastReviewed) =>
|
|
756
759
|
`Prices for ${count} ${count === 1 ? 'model' : 'models'} came from a local overlay reviewed ${lastReviewed}.`,
|
|
757
|
-
sourceEstimated: () => 'estimated, ±
|
|
760
|
+
sourceEstimated: () => 'estimated, ±10%',
|
|
758
761
|
sourceExact: () => 'counted exactly',
|
|
759
762
|
measuringOptimised: () =>
|
|
760
763
|
'Measuring what the rules would leave, not what is written in the file.',
|
|
@@ -799,6 +802,36 @@ ${bold('EXAMPLES')}
|
|
|
799
802
|
exactCountsCost: (files) =>
|
|
800
803
|
`Counting ${files} ${files === 1 ? 'file' : 'files'} through the API, one call each. This takes a moment.`,
|
|
801
804
|
},
|
|
805
|
+
profile: {
|
|
806
|
+
noTarget: () =>
|
|
807
|
+
'Point this at a usage log: trazum profile usage.jsonl — one JSON object per line, each with a "model" and the "usage" object the API returned. Recording one is three lines in your own code, and it never contains prompt text.',
|
|
808
|
+
heading: () => 'Where the money went',
|
|
809
|
+
spent: (calls, total) => `${calls} calls · ${total}`,
|
|
810
|
+
part: (name, usd, pct, tokens) => `${name.padEnd(13)}${usd.padStart(11)} ${pct.padStart(5)} ${tokens} tokens`,
|
|
811
|
+
partInput: () => 'Input',
|
|
812
|
+
partCacheRead: () => 'Cache reads',
|
|
813
|
+
partCacheWrite: () => 'Cache writes',
|
|
814
|
+
partOutput: () => 'Output',
|
|
815
|
+
byLabelHeading: () => 'By label',
|
|
816
|
+
byModelHeading: () => 'By model',
|
|
817
|
+
row: (name, usd, pct, calls) => `${usd.padStart(11)} ${pct.padStart(5)} ${name} (${calls} calls)`,
|
|
818
|
+
unlabelled: () => 'unlabelled',
|
|
819
|
+
cacheHit: (pct) => `Cache hit rate ${pct} of billable input.`,
|
|
820
|
+
cacheNever: () => 'Caching was never used on these calls. If any prefix repeats, that is the largest saving available.',
|
|
821
|
+
biggestPart: (name, pct) => `${name} is ${pct} of this bill.`,
|
|
822
|
+
outputDominates: (pct) =>
|
|
823
|
+
`Output is ${pct} of this bill, so shortening prompts has a low ceiling here. What moves it is asking for shorter answers and capping max_tokens.`,
|
|
824
|
+
unpriced: (models, calls) =>
|
|
825
|
+
`${count(calls)} ${calls === 1 ? 'call is' : 'calls are'} not in these totals — the pricing catalogue does not know: ${models}. Add them with a pricing overlay (--pricing) to include them.`,
|
|
826
|
+
skipped: (lineCount, lines) =>
|
|
827
|
+
`${count(lineCount)} ${lineCount === 1 ? 'line' : 'lines'} could not be read and ${lineCount === 1 ? 'was' : 'were'} left out (${lineCount === 1 ? 'line' : 'lines'} ${lines}).`,
|
|
828
|
+
empty: () => 'No usage records in that file.',
|
|
829
|
+
nothingPriced: () =>
|
|
830
|
+
'None of the models in that log are in the pricing catalogue, so there is no bill to report. Add them with a pricing overlay (--pricing) and run this again.',
|
|
831
|
+
assumedWriteTtl: (calls) =>
|
|
832
|
+
`${count(calls)} ${calls === 1 ? 'call did' : 'calls did'} not say which cache-write TTL was used, so the cheaper 5-minute rate was assumed. A 1-hour entry costs 2x input rather than 1.25x, so this total is a floor for those calls. Record the "cache_creation" object the API returns to remove the assumption.`,
|
|
833
|
+
},
|
|
834
|
+
|
|
802
835
|
baseline: {
|
|
803
836
|
recorded: (path, files, tokens) =>
|
|
804
837
|
`Recorded ${files} prompts, ${tokens} tokens, to ${path}. Commit it — the gate compares the tree against what is committed.`,
|
package/src/i18n/es.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CliMessages } from './types.js';
|
|
2
2
|
|
|
3
|
+
/** Counts, grouped. A log with forty thousand torn lines should say so legibly. */
|
|
4
|
+
const count = (value: number): string => value.toLocaleString('es-ES');
|
|
5
|
+
|
|
3
6
|
/** "(hace 46 días)", o nada cuando no se sabe la antigüedad. */
|
|
4
7
|
const hace = (days: number | null): string =>
|
|
5
8
|
days === null ? '' : days === 0 ? ' (hoy)' : days === 1 ? ' (hace 1 día)' : ` (hace ${days} días)`;
|
|
@@ -376,7 +379,7 @@ ${bold('EJEMPLOS')}
|
|
|
376
379
|
inputTokens: () => 'Tokens de entrada',
|
|
377
380
|
estimated: (offFamily) =>
|
|
378
381
|
offFamily === null
|
|
379
|
-
? ' (estimado, ±
|
|
382
|
+
? ' (estimado, ±10%)'
|
|
380
383
|
: ` (estimado — el contador está calibrado sobre Claude, no sobre ${offFamily})`,
|
|
381
384
|
exactCount: () => ' (recuento exacto)',
|
|
382
385
|
rulesApplied: () => 'Reglas aplicadas',
|
|
@@ -565,7 +568,7 @@ ${bold('EJEMPLOS')}
|
|
|
565
568
|
`Mostrando las ${shown} más recientes. Usa --limit para ver más.`,
|
|
566
569
|
followedRename: (from) => `Se ha seguido un renombrado: las revisiones anteriores son ${from}.`,
|
|
567
570
|
estimateNote: () =>
|
|
568
|
-
'Los recuentos son estimaciones (±
|
|
571
|
+
'Los recuentos son estimaciones (±10%). Lo que importa es la tendencia, no las cifras absolutas.',
|
|
569
572
|
},
|
|
570
573
|
|
|
571
574
|
languages: {
|
|
@@ -766,7 +769,7 @@ ${bold('EJEMPLOS')}
|
|
|
766
769
|
footer: (source, level) => `Recuento de tokens ${source} · nivel de reglas \`${level}\``,
|
|
767
770
|
pricingOverlaid: (count, lastReviewed) =>
|
|
768
771
|
`Los precios de ${count} ${count === 1 ? 'modelo' : 'modelos'} vienen de un overlay local revisado el ${lastReviewed}.`,
|
|
769
|
-
sourceEstimated: () => 'estimado, ±
|
|
772
|
+
sourceEstimated: () => 'estimado, ±10%',
|
|
770
773
|
sourceExact: () => 'exacto',
|
|
771
774
|
measuringOptimised: () =>
|
|
772
775
|
'Se mide lo que dejarían las reglas, no lo que está escrito en el fichero.',
|
|
@@ -811,6 +814,36 @@ ${bold('EJEMPLOS')}
|
|
|
811
814
|
exactCountsCost: (files) =>
|
|
812
815
|
`Contando ${files} ${files === 1 ? 'fichero' : 'ficheros'} con la API, una llamada por cada uno. Esto tarda un momento.`,
|
|
813
816
|
},
|
|
817
|
+
profile: {
|
|
818
|
+
noTarget: () =>
|
|
819
|
+
'Apúntalo a un log de uso: trazum profile usage.jsonl — un objeto JSON por línea, cada uno con un "model" y el objeto "usage" que devolvió la API. Registrarlo son tres líneas en tu propio código, y nunca contiene el texto del prompt.',
|
|
820
|
+
heading: () => 'Adónde fue el dinero',
|
|
821
|
+
spent: (calls, total) => `${calls} llamadas · ${total}`,
|
|
822
|
+
part: (name, usd, pct, tokens) => `${name.padEnd(15)}${usd.padStart(11)} ${pct.padStart(5)} ${tokens} tokens`,
|
|
823
|
+
partInput: () => 'Entrada',
|
|
824
|
+
partCacheRead: () => 'Lecturas caché',
|
|
825
|
+
partCacheWrite: () => 'Escrituras caché',
|
|
826
|
+
partOutput: () => 'Salida',
|
|
827
|
+
byLabelHeading: () => 'Por etiqueta',
|
|
828
|
+
byModelHeading: () => 'Por modelo',
|
|
829
|
+
row: (name, usd, pct, calls) => `${usd.padStart(11)} ${pct.padStart(5)} ${name} (${calls} llamadas)`,
|
|
830
|
+
unlabelled: () => 'sin etiqueta',
|
|
831
|
+
cacheHit: (pct) => `Tasa de acierto de caché: ${pct} de la entrada facturable.`,
|
|
832
|
+
cacheNever: () => 'No se usó caché en estas llamadas. Si algún prefijo se repite, ese es el mayor ahorro disponible.',
|
|
833
|
+
biggestPart: (name, pct) => `${name} es el ${pct} de esta factura.`,
|
|
834
|
+
outputDominates: (pct) =>
|
|
835
|
+
`La salida es el ${pct} de esta factura, así que acortar prompts tiene un techo bajo aquí. Lo que mueve la aguja es pedir respuestas más cortas y limitar max_tokens.`,
|
|
836
|
+
unpriced: (models, calls) =>
|
|
837
|
+
`${count(calls)} ${calls === 1 ? 'llamada no está' : 'llamadas no están'} en estos totales: el catálogo de precios no conoce ${models}. Añádelos con un overlay de precios (--pricing) para incluirlos.`,
|
|
838
|
+
skipped: (lineCount, lines) =>
|
|
839
|
+
`No se ${lineCount === 1 ? 'pudo leer' : 'pudieron leer'} ${count(lineCount)} ${lineCount === 1 ? 'línea y quedó fuera' : 'líneas y quedaron fuera'} (${lineCount === 1 ? 'línea' : 'líneas'} ${lines}).`,
|
|
840
|
+
empty: () => 'No hay registros de uso en ese archivo.',
|
|
841
|
+
nothingPriced: () =>
|
|
842
|
+
'Ninguno de los modelos de ese log está en el catálogo de precios, así que no hay factura que reportar. Añádelos con un overlay de precios (--pricing) y vuelve a ejecutarlo.',
|
|
843
|
+
assumedWriteTtl: (calls) =>
|
|
844
|
+
`${count(calls)} ${calls === 1 ? 'llamada no dijo' : 'llamadas no dijeron'} qué TTL de escritura de caché se usó, así que se asumió la tarifa más barata, la de 5 minutos. Una entrada de 1 hora cuesta 2x la entrada en vez de 1.25x, así que este total es un suelo para esas llamadas. Registra el objeto "cache_creation" que devuelve la API para quitar la suposición.`,
|
|
845
|
+
},
|
|
846
|
+
|
|
814
847
|
baseline: {
|
|
815
848
|
recorded: (path, files, tokens) =>
|
|
816
849
|
`Registrados ${files} prompts, ${tokens} tokens, en ${path}. Haz commit: la puerta compara el \u00e1rbol con lo que est\u00e9 commiteado.`,
|
package/src/i18n/types.ts
CHANGED
|
@@ -351,6 +351,32 @@ export interface CliMessages {
|
|
|
351
351
|
* get worse" rather than "does this fit" — and both verdicts appear in the
|
|
352
352
|
* same run.
|
|
353
353
|
*/
|
|
354
|
+
profile: {
|
|
355
|
+
noTarget(): string;
|
|
356
|
+
heading(): string;
|
|
357
|
+
/** Totals line: calls and the bill they came to. */
|
|
358
|
+
spent(calls: string, total: string): string;
|
|
359
|
+
/** One row of the split, with its share of the bill. */
|
|
360
|
+
part(name: string, usd: string, pct: string, tokens: string): string;
|
|
361
|
+
partInput(): string;
|
|
362
|
+
partCacheRead(): string;
|
|
363
|
+
partCacheWrite(): string;
|
|
364
|
+
partOutput(): string;
|
|
365
|
+
byLabelHeading(): string;
|
|
366
|
+
byModelHeading(): string;
|
|
367
|
+
row(name: string, usd: string, pct: string, calls: string): string;
|
|
368
|
+
unlabelled(): string;
|
|
369
|
+
cacheHit(pct: string): string;
|
|
370
|
+
cacheNever(): string;
|
|
371
|
+
/** The finding the whole command exists to produce. */
|
|
372
|
+
biggestPart(name: string, pct: string): string;
|
|
373
|
+
outputDominates(pct: string): string;
|
|
374
|
+
unpriced(models: string, calls: number): string;
|
|
375
|
+
skipped(count: number, lines: string): string;
|
|
376
|
+
empty(): string;
|
|
377
|
+
nothingPriced(): string;
|
|
378
|
+
assumedWriteTtl(calls: number): string;
|
|
379
|
+
};
|
|
354
380
|
baseline: {
|
|
355
381
|
recorded(path: string, files: string, tokens: string): string;
|
|
356
382
|
recordedMoney(monthly: string, model: string, calls: string): string;
|
package/src/index.ts
CHANGED
|
@@ -3,52 +3,56 @@ import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join, resolve as resolvePath } from 'node:path';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
applyRewrites,
|
|
6
7
|
BASELINE_FILENAME,
|
|
7
8
|
BASELINE_VERSION,
|
|
8
|
-
MAX_BASELINE_BYTES,
|
|
9
9
|
breaches,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
moneyIsComparable,
|
|
13
|
-
parseBaseline,
|
|
14
|
-
DEFAULT_USAGE,
|
|
15
|
-
LOCALES,
|
|
16
|
-
PRICING_LAST_REVIEWED,
|
|
17
|
-
reviewAgeDays,
|
|
18
|
-
RULES,
|
|
10
|
+
cacheableMinimum,
|
|
11
|
+
cacheHitRate,
|
|
19
12
|
comparePrompts,
|
|
20
|
-
|
|
21
|
-
getModel,
|
|
22
|
-
applyRewrites,
|
|
13
|
+
compareToBaseline,
|
|
23
14
|
computeSavings,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
countTokensAnthropic,
|
|
16
|
+
DEFAULT_USAGE,
|
|
17
|
+
detectFromSource,
|
|
27
18
|
estimateTokens,
|
|
28
|
-
|
|
19
|
+
evaluate,
|
|
20
|
+
extractPrompts,
|
|
21
|
+
findExamples,
|
|
22
|
+
formatBaseline,
|
|
29
23
|
formatSignedUsd,
|
|
24
|
+
formatUsd,
|
|
30
25
|
getMessages,
|
|
26
|
+
getModel,
|
|
27
|
+
hasMarker,
|
|
31
28
|
listModels,
|
|
29
|
+
LOCALES,
|
|
30
|
+
MAX_BASELINE_BYTES,
|
|
31
|
+
moneyIsComparable,
|
|
32
32
|
nearestName,
|
|
33
33
|
optimize,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
reorderForCache,
|
|
37
|
-
sharedPrefixes,
|
|
38
|
-
cacheableMinimum,
|
|
39
|
-
findExamples,
|
|
34
|
+
parseBaseline,
|
|
35
|
+
PHRASE_LANGUAGES,
|
|
40
36
|
plannedCalls,
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
PRICING_LAST_REVIEWED,
|
|
38
|
+
profilePrompt,
|
|
39
|
+
profileUsage,
|
|
43
40
|
promptId,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
detectFromSource,
|
|
47
|
-
evaluate,
|
|
41
|
+
providerFromEnv,
|
|
42
|
+
pruneExamples,
|
|
48
43
|
refineWithLlm,
|
|
49
44
|
rejectionText,
|
|
50
|
-
|
|
45
|
+
reorderForCache,
|
|
46
|
+
reviewAgeDays,
|
|
51
47
|
reviewExamples,
|
|
48
|
+
RULES,
|
|
49
|
+
sharedPrefixes,
|
|
50
|
+
sharesOf,
|
|
51
|
+
SOURCE_EXTENSIONS,
|
|
52
|
+
suggestRewrites,
|
|
53
|
+
toOtlpMetrics,
|
|
54
|
+
toPromptfoo,
|
|
55
|
+
UNLABELLED,
|
|
52
56
|
withExactTokenCounts,
|
|
53
57
|
} from '@trazum/core';
|
|
54
58
|
import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
|
|
@@ -404,6 +408,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
404
408
|
],
|
|
405
409
|
check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
|
|
406
410
|
baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
|
|
411
|
+
profile: ['json', 'pricing', 'pricing-live'],
|
|
407
412
|
eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
|
|
408
413
|
prune: ['cases', 'concurrency', 'json', 'yes'],
|
|
409
414
|
diff: ['level', 'model', 'calls', 'output-tokens', 'batch', 'max-growth', 'optimized', 'markdown-out', 'all', 'prompt'],
|
|
@@ -504,7 +509,7 @@ function renderDiff(before: string, after: string, t: CliMessages): string {
|
|
|
504
509
|
* The provider's name when the estimator was not calibrated for it.
|
|
505
510
|
*
|
|
506
511
|
* `estimateTokens` is a heuristic tuned against Claude's tokenizer, and the
|
|
507
|
-
* ±
|
|
512
|
+
* ±10% band descends from that. Printing the same band beside a GPT or Kimi
|
|
508
513
|
* figure states a precision nobody has measured for that family — and since the
|
|
509
514
|
* catalogue grew past Anthropic, that is most of it. Returns null when the model
|
|
510
515
|
* is Anthropic's, where the band is at least the claim it was written for.
|
|
@@ -527,6 +532,59 @@ function languageNames(codes: readonly string[], t: CliMessages): string {
|
|
|
527
532
|
return `${names.slice(0, -1).join(', ')} ${t.languages.and} ${names[names.length - 1]}`;
|
|
528
533
|
}
|
|
529
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Advisories whose entire pitch is money.
|
|
537
|
+
*
|
|
538
|
+
* On a subscription these are not weaker advice, they are not advice: "use a
|
|
539
|
+
* cheaper model" saves nothing on a flat plan, and its detail text quotes dollars
|
|
540
|
+
* per month, so suppressing only the price tag beside the title left the money in
|
|
541
|
+
* the sentence underneath.
|
|
542
|
+
*/
|
|
543
|
+
const MONEY_ONLY_ADVISORIES: ReadonlySet<string> = new Set([
|
|
544
|
+
'model-downgrade',
|
|
545
|
+
'batch-api',
|
|
546
|
+
'output-dominated',
|
|
547
|
+
'promo-pricing',
|
|
548
|
+
'prompt-caching-not-worth-it',
|
|
549
|
+
]);
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The one thing worth doing about this prompt, and how it compares to shortening it.
|
|
553
|
+
*
|
|
554
|
+
* `null` when there is nothing to say: no advisory carries a figure, or the
|
|
555
|
+
* reader is on a subscription where a monthly saving is meaningless. A heading
|
|
556
|
+
* with a shrug under it is worse than no heading.
|
|
557
|
+
*/
|
|
558
|
+
function biggestLever(
|
|
559
|
+
result: OptimizationResult,
|
|
560
|
+
tokensOnly: boolean,
|
|
561
|
+
t: CliMessages,
|
|
562
|
+
): { line: string } | null {
|
|
563
|
+
/**
|
|
564
|
+
* One guard, and it is the only thing deciding.
|
|
565
|
+
*
|
|
566
|
+
* The first version also filtered the candidate list by `!tokensOnly`, which
|
|
567
|
+
* duplicated this and made it untestable: removing the guard left the filter
|
|
568
|
+
* still suppressing the line, so a mutation that priced a subscription passed
|
|
569
|
+
* the suite. Two checks for one condition is one check and one place for a bug.
|
|
570
|
+
*/
|
|
571
|
+
if (tokensOnly) return null;
|
|
572
|
+
const best = result.advisories.find((a) => (a.estimatedMonthlyUsd ?? 0) > 0);
|
|
573
|
+
if (!best?.estimatedMonthlyUsd) return null;
|
|
574
|
+
|
|
575
|
+
const ruleSaving = result.savings.monthlySavingsUsd;
|
|
576
|
+
return {
|
|
577
|
+
line: t.report.biggestLeverDetail(
|
|
578
|
+
best.title,
|
|
579
|
+
formatUsd(best.estimatedMonthlyUsd),
|
|
580
|
+
// The multiple is the point of the line, and it is only honest when there
|
|
581
|
+
// is something to divide by. A prompt the rules could not improve at all
|
|
582
|
+
// gets the amount and no ratio rather than a division by zero dressed up.
|
|
583
|
+
ruleSaving > 0 ? Math.round(best.estimatedMonthlyUsd / ruleSaving) : null,
|
|
584
|
+
),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
530
588
|
function printReport(
|
|
531
589
|
result: OptimizationResult,
|
|
532
590
|
showDiff: boolean,
|
|
@@ -543,6 +601,32 @@ function printReport(
|
|
|
543
601
|
? c.dim(t.report.estimated(offFamilyName(result.usage.model)))
|
|
544
602
|
: c.dim(t.report.exactCount());
|
|
545
603
|
|
|
604
|
+
/**
|
|
605
|
+
* The largest lever, first.
|
|
606
|
+
*
|
|
607
|
+
* This line used to be the last thing in the report and it is the most useful
|
|
608
|
+
* thing in it. Measured on an ordinary support prompt — already reasonably
|
|
609
|
+
* written, which is what a real one is — the rules recover **three tokens of
|
|
610
|
+
* 306**, worth $0.75 a month, while the cache reorder sitting below them is
|
|
611
|
+
* worth $48. The report opened with the 1.3% and closed with the 64×.
|
|
612
|
+
*
|
|
613
|
+
* That ordering is not a presentation quibble. It teaches the reader that
|
|
614
|
+
* shortening the prompt is what this tool is for, and on any prompt somebody
|
|
615
|
+
* competent wrote, shortening it is the smallest thing available. The rules
|
|
616
|
+
* earn their keep on genuine bloat — a duplicated paragraph, "due to the fact
|
|
617
|
+
* that" — and recover close to nothing once that is gone, because they recover
|
|
618
|
+
* waste rather than creating savings.
|
|
619
|
+
*
|
|
620
|
+
* So the answer to "what should I do about this prompt" goes at the top, and
|
|
621
|
+
* the token count follows as the detail it is.
|
|
622
|
+
*/
|
|
623
|
+
const best = biggestLever(result, tokensOnly, t);
|
|
624
|
+
if (best) {
|
|
625
|
+
console.log();
|
|
626
|
+
console.log(c.bold(t.report.biggestLever()));
|
|
627
|
+
console.log(` ${c.dim(wrap(best.line, 74, ' '))}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
546
630
|
console.log();
|
|
547
631
|
console.log(c.bold(t.report.inputTokens()));
|
|
548
632
|
console.log(
|
|
@@ -668,13 +752,7 @@ function printReport(
|
|
|
668
752
|
// The rest stay: an overflowing context window still fails the call, a
|
|
669
753
|
// contradiction is still wrong, redundant examples still cost tokens, and
|
|
670
754
|
// caching still buys latency and rate-limit headroom.
|
|
671
|
-
const MONEY_ONLY =
|
|
672
|
-
'model-downgrade',
|
|
673
|
-
'batch-api',
|
|
674
|
-
'output-dominated',
|
|
675
|
-
'promo-pricing',
|
|
676
|
-
'prompt-caching-not-worth-it',
|
|
677
|
-
]);
|
|
755
|
+
const MONEY_ONLY = MONEY_ONLY_ADVISORIES;
|
|
678
756
|
const advisories = tokensOnly
|
|
679
757
|
? result.advisories.filter((a) => !MONEY_ONLY.has(a.id))
|
|
680
758
|
: result.advisories;
|
|
@@ -711,23 +789,8 @@ function printReport(
|
|
|
711
789
|
console.log(`${gutter}${c.dim(wrap(advisory.detail, 78 - gutter.length, gutter))}`);
|
|
712
790
|
}
|
|
713
791
|
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// sentences is how the most valuable line in the report gets skipped.
|
|
717
|
-
const best = advisories.find((a) => (a.estimatedMonthlyUsd ?? 0) > 0);
|
|
718
|
-
if (!tokensOnly && best?.estimatedMonthlyUsd) {
|
|
719
|
-
const ruleSaving = result.savings.monthlySavingsUsd;
|
|
720
|
-
const line = t.report.biggestLeverDetail(
|
|
721
|
-
best.title,
|
|
722
|
-
formatUsd(best.estimatedMonthlyUsd),
|
|
723
|
-
ruleSaving > 0 ? Math.round(best.estimatedMonthlyUsd / ruleSaving) : null,
|
|
724
|
-
);
|
|
725
|
-
console.log();
|
|
726
|
-
// Wrapped to the same width as everything else. An unwrapped closing line
|
|
727
|
-
// is the one that runs off a narrow terminal, and it is the line most
|
|
728
|
-
// worth reading.
|
|
729
|
-
console.log(` ${c.bold(t.report.biggestLever())} ${c.dim(wrap(line, 62, ' '))}`);
|
|
730
|
-
}
|
|
792
|
+
// The "start here" line is printed at the top of the report now, where a
|
|
793
|
+
// reader who stops after four lines still sees it.
|
|
731
794
|
}
|
|
732
795
|
|
|
733
796
|
printSuggestions(suggestions, t, n);
|
|
@@ -1802,6 +1865,156 @@ function isoDate(): string {
|
|
|
1802
1865
|
return new Date().toISOString().slice(0, 10);
|
|
1803
1866
|
}
|
|
1804
1867
|
|
|
1868
|
+
/**
|
|
1869
|
+
* `trazum profile <log.jsonl>` — where the money actually went.
|
|
1870
|
+
*
|
|
1871
|
+
* Every other command in this file reads a prompt and reasons forward about what
|
|
1872
|
+
* it would cost. This one reads what the provider charged and reasons backward,
|
|
1873
|
+
* and it exists because the forward direction can only see the smallest line item:
|
|
1874
|
+
* on an ordinary support prompt the rules recover about 1% of the monthly figure
|
|
1875
|
+
* while output alone was 87% of it.
|
|
1876
|
+
*
|
|
1877
|
+
* **Money is never suppressed here, unlike every other report.** The rest of the
|
|
1878
|
+
* CLI hides dollar figures on a subscription host, because a saving quoted to
|
|
1879
|
+
* somebody on a flat plan is money that does not exist. This log is a record of
|
|
1880
|
+
* metered API calls somebody was actually billed for — the bill exists wherever
|
|
1881
|
+
* Trazum happens to be running, so the host has no bearing on it.
|
|
1882
|
+
*/
|
|
1883
|
+
async function commandProfile(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
|
|
1884
|
+
const path = args.positional[0];
|
|
1885
|
+
if (path === undefined) {
|
|
1886
|
+
console.log();
|
|
1887
|
+
console.log(c.dim(wrap(t.profile.noTarget(), 74, ' ')));
|
|
1888
|
+
console.log();
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const raw = await readFile(path, 'utf8');
|
|
1893
|
+
const report = profileUsage(raw, { catalogue: pricing });
|
|
1894
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
1895
|
+
const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
|
|
1896
|
+
|
|
1897
|
+
if (boolFlag(args, 'json')) {
|
|
1898
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Nothing priced means there is no report, not a report of zero.
|
|
1904
|
+
*
|
|
1905
|
+
* The guard was `total.calls === 0 && unpriced.calls === 0`, so a log whose every
|
|
1906
|
+
* model was unknown fell through and printed a full report built from a zeroed
|
|
1907
|
+
* total: `0 calls · $0`, four `$0 / 0.0%` rows, a meaningless "Input is 0.0% of
|
|
1908
|
+
* this bill", and — on a log containing a hundred thousand cache-read tokens —
|
|
1909
|
+
* the flatly false "Caching was never used on these calls".
|
|
1910
|
+
*
|
|
1911
|
+
* Two affirmatively wrong claims and a $0 headline for a real bill. The trailing
|
|
1912
|
+
* unpriced note was the only correct line on screen, and it was the quietest.
|
|
1913
|
+
*/
|
|
1914
|
+
if (report.total.calls === 0) {
|
|
1915
|
+
console.log();
|
|
1916
|
+
console.log(c.dim(report.unpriced.calls === 0 ? t.profile.empty() : t.profile.nothingPriced()));
|
|
1917
|
+
reportProfileGaps(report, t, n);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
const shares = sharesOf(report.total);
|
|
1922
|
+
const parts: Array<[string, number, number, number]> = [
|
|
1923
|
+
[t.profile.partInput(), report.total.inputUsd, shares.input, report.total.inputTokens],
|
|
1924
|
+
[t.profile.partCacheRead(), report.total.cacheReadUsd, shares.cacheRead, report.total.cacheReadTokens],
|
|
1925
|
+
[t.profile.partCacheWrite(), report.total.cacheWriteUsd, shares.cacheWrite, report.total.cacheWriteTokens],
|
|
1926
|
+
[t.profile.partOutput(), report.total.outputUsd, shares.output, report.total.outputTokens],
|
|
1927
|
+
];
|
|
1928
|
+
|
|
1929
|
+
console.log();
|
|
1930
|
+
console.log(c.bold(t.profile.heading()));
|
|
1931
|
+
console.log(` ${t.profile.spent(n(report.total.calls), formatUsd(report.total.totalUsd))}`);
|
|
1932
|
+
console.log();
|
|
1933
|
+
// Every part, including the zero ones. A row missing because it was zero reads
|
|
1934
|
+
// as a row somebody forgot, and "you are not caching at all" is a finding.
|
|
1935
|
+
for (const [name, usd, share, tokens] of parts) {
|
|
1936
|
+
console.log(` ${c.dim(t.profile.part(name, formatUsd(usd), pct(share), n(tokens)))}`);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* The line the command exists for: which part of the bill to argue with.
|
|
1941
|
+
*
|
|
1942
|
+
* When output is both the biggest part and over half, the two sentences say the
|
|
1943
|
+
* same thing and the second says more — so only the second prints. Reporting a
|
|
1944
|
+
* fact twice in adjacent lines reads as a bug, and it was one.
|
|
1945
|
+
*/
|
|
1946
|
+
const [biggestName, , biggestShare] = parts.reduce((a, b) => (b[1] > a[1] ? b : a));
|
|
1947
|
+
const outputDominates = shares.output > 0.5;
|
|
1948
|
+
console.log();
|
|
1949
|
+
if (outputDominates) {
|
|
1950
|
+
console.log(` ${c.bold(wrap(t.profile.outputDominates(pct(shares.output)), 74, ' '))}`);
|
|
1951
|
+
} else {
|
|
1952
|
+
console.log(` ${c.bold(t.profile.biggestPart(biggestName, pct(biggestShare)))}`);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const hitRate = cacheHitRate(report.total);
|
|
1956
|
+
console.log(
|
|
1957
|
+
hitRate === null
|
|
1958
|
+
? ` ${c.dim(wrap(t.profile.cacheNever(), 74, ' '))}`
|
|
1959
|
+
: ` ${c.dim(t.profile.cacheHit(pct(hitRate)))}`,
|
|
1960
|
+
);
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* A total that assumed a cache-write rate is a floor, and says so.
|
|
1964
|
+
*
|
|
1965
|
+
* Anthropic's 1-hour entry costs 2x input against the 5-minute entry's 1.25x. A
|
|
1966
|
+
* log carrying only the flat `cache_creation_input_tokens` cannot say which, so
|
|
1967
|
+
* the cheaper one is used — and the flattering direction is exactly the one this
|
|
1968
|
+
* tool refuses to take quietly.
|
|
1969
|
+
*/
|
|
1970
|
+
if (report.total.assumedWriteTtlCalls > 0) {
|
|
1971
|
+
console.log(
|
|
1972
|
+
` ${c.dim(wrap(t.profile.assumedWriteTtl(report.total.assumedWriteTtlCalls), 74, ' '))}`,
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
for (const [heading, rows] of [
|
|
1977
|
+
[t.profile.byLabelHeading(), report.byLabel.map((r) => [r.label === UNLABELLED ? t.profile.unlabelled() : r.label, r.breakdown] as const)],
|
|
1978
|
+
[t.profile.byModelHeading(), report.byModel.map((r) => [r.model, r.breakdown] as const)],
|
|
1979
|
+
] as const) {
|
|
1980
|
+
if (rows.length <= 1) continue; // One row is the total again, said twice.
|
|
1981
|
+
console.log();
|
|
1982
|
+
console.log(c.bold(heading));
|
|
1983
|
+
for (const [name, breakdown] of rows) {
|
|
1984
|
+
const share = report.total.totalUsd > 0 ? breakdown.totalUsd / report.total.totalUsd : 0;
|
|
1985
|
+
console.log(` ${t.profile.row(name, formatUsd(breakdown.totalUsd), pct(share), n(breakdown.calls))}`);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
reportProfileGaps(report, t, n);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* What the profile could not account for, said out loud.
|
|
1994
|
+
*
|
|
1995
|
+
* Separated so both the empty and the populated path print it. A total that
|
|
1996
|
+
* silently omits calls is wrong in the flattering direction, which is the fault
|
|
1997
|
+
* this repository keeps finding in itself.
|
|
1998
|
+
*/
|
|
1999
|
+
function reportProfileGaps(
|
|
2000
|
+
report: ReturnType<typeof profileUsage>,
|
|
2001
|
+
t: CliMessages,
|
|
2002
|
+
n: (value: number) => string,
|
|
2003
|
+
): void {
|
|
2004
|
+
if (report.unpricedModels.length > 0) {
|
|
2005
|
+
console.log();
|
|
2006
|
+
console.log(
|
|
2007
|
+
` ${c.yellow('!')} ${c.dim(wrap(t.profile.unpriced(report.unpricedModels.join(', '), report.unpriced.calls), 74, ' '))}`,
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
if (report.skippedLines.length > 0) {
|
|
2011
|
+
const shown = report.skippedLines.slice(0, 5).join(', ');
|
|
2012
|
+
console.log(
|
|
2013
|
+
` ${c.dim(t.profile.skipped(report.skippedLines.length, report.skippedLines.length > 5 ? `${shown}…` : shown))}`,
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1805
2018
|
/**
|
|
1806
2019
|
* `trazum baseline <dir>` — record what the estate costs now.
|
|
1807
2020
|
*
|
|
@@ -3292,6 +3505,9 @@ async function main(): Promise<void> {
|
|
|
3292
3505
|
case 'baseline':
|
|
3293
3506
|
await commandBaseline(args, config, pricing, t, locale);
|
|
3294
3507
|
break;
|
|
3508
|
+
case 'profile':
|
|
3509
|
+
await commandProfile(args, pricing, t);
|
|
3510
|
+
break;
|
|
3295
3511
|
case 'eval':
|
|
3296
3512
|
await commandEval(args, config, t, locale);
|
|
3297
3513
|
break;
|