@trazum/cli 1.35.0 → 1.36.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 +11 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +35 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +35 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +29 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +198 -23
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +50 -0
- package/src/i18n/es.ts +50 -0
- package/src/i18n/types.ts +29 -0
- package/src/index.ts +250 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.0",
|
|
4
4
|
"description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "David Mu\u00f1oz Rey",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"prepublishOnly": "npm run build && npm test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@trazum/core": "1.
|
|
40
|
+
"@trazum/core": "1.36.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^26.2.0",
|
package/src/i18n/en.ts
CHANGED
|
@@ -119,6 +119,20 @@ ${bold('OPTIONS FOR optimize')}
|
|
|
119
119
|
--calls <n> Calls per month. Default: ${d.callsPerMonth}.
|
|
120
120
|
--output-tokens <n> Average output tokens. Default: ${d.avgOutputTokens}.
|
|
121
121
|
--cache-hit-rate <0-1> Estimated cache hit rate. Default: ${d.cacheHitRate}.
|
|
122
|
+
--all-labels With --from-log: every prompt in the config's
|
|
123
|
+
"labels" map, optimised and priced against its
|
|
124
|
+
own measured traffic, ranked by what the change
|
|
125
|
+
is worth — plus the mismatches in both
|
|
126
|
+
directions (mapped prompts with no traffic,
|
|
127
|
+
traffic with no mapped prompt).
|
|
128
|
+
--from-log <usage.jsonl> Measure the three figures above from a usage log
|
|
129
|
+
instead of typing them: real call count, real
|
|
130
|
+
output size, real cache share, and the model the
|
|
131
|
+
calls actually went to. Refuses the typed flags
|
|
132
|
+
beside it, scales to a month only past a full
|
|
133
|
+
week of data, and says which figures are
|
|
134
|
+
measured. Pair with --label, or map the prompt
|
|
135
|
+
under "labels" in trazum.config.json.
|
|
122
136
|
--batch The work tolerates latency (Batch API, 50% off).
|
|
123
137
|
--disable <id,id> Turn off specific rules (see "trazum rules").
|
|
124
138
|
--suggest Ask the LLM for phrase-level rewrites and list them
|
|
@@ -444,6 +458,18 @@ ${bold('EXAMPLES')}
|
|
|
444
458
|
mustBeNonNegative: (name, raw) =>
|
|
445
459
|
`--${name} must be a non-negative number (received: "${raw}").`,
|
|
446
460
|
badLevel: (received) => `--level must be "safe" or "aggressive" (received: "${received}").`,
|
|
461
|
+
allLabelsNeedsLog: () =>
|
|
462
|
+
'--all-labels ranks prompts by measured traffic, so it needs --from-log <usage.jsonl>. Without a log every saving would be multiplied by the same typed guess, which ranks prompts by length and calls it a priority.',
|
|
463
|
+
allLabelsNeedsMap: () =>
|
|
464
|
+
'--all-labels reads the "labels" map in trazum.config.json — label to prompt file — and this config has none. Map at least one workload to its prompt.',
|
|
465
|
+
fromLogConflict: (flag) =>
|
|
466
|
+
`--from-log measures the figure --${flag} types, and merging a measurement with a guess produces a number that is neither. Pass one or the other.`,
|
|
467
|
+
fromLogNeedsLabel: (available) =>
|
|
468
|
+
`--from-log needs to know which workload this prompt is: pass --label, or map the prompt file under "labels" in trazum.config.json. Labels with traffic in this log: ${available}.`,
|
|
469
|
+
fromLogAmbiguousLabel: (target, labels) =>
|
|
470
|
+
`${target} is mapped to more than one label in trazum.config.json (${labels}), so --from-log cannot pick one silently. Pass --label.`,
|
|
471
|
+
fromLogLabelEmpty: (label, available) =>
|
|
472
|
+
`No priced call in this log carries the label "${label}", so there is nothing to measure — a zero-call profile would price this change as worthless rather than as unmeasured. Labels with traffic: ${available}.`,
|
|
447
473
|
unknownRuleInDisable: (id) => `Unknown rule in --disable: "${id}". Full list: trazum rules`,
|
|
448
474
|
unknownCommand: (command) => `Unknown command: "${command}". Try "trazum --help".`,
|
|
449
475
|
missingInputFile: () => 'Missing input file. Use "-" to read from standard input.',
|
|
@@ -510,6 +536,30 @@ ${bold('EXAMPLES')}
|
|
|
510
536
|
costWith: (modelName) => `Cost with ${modelName}`,
|
|
511
537
|
usageLine: (calls, outputTokens, batch) =>
|
|
512
538
|
`${calls} calls/month · ${outputTokens} output tokens per call${batch ? ' · Batch API' : ''}`,
|
|
539
|
+
allLabelsHeading: (count) => `Every mapped prompt against its own measured traffic — ${count} ranked by what the change is worth`,
|
|
540
|
+
allLabelsRow: (saving) => `${saving}/month if optimised`,
|
|
541
|
+
allLabelsRowPeriod: (saving) => `${saving} over the measured period if optimised`,
|
|
542
|
+
allLabelsFooter: () =>
|
|
543
|
+
'Ranked by measured traffic, not by prompt length: a big prompt on a dead workload is worth less than a small one on a busy one. Each figure is this prompt\'s token delta at its own label\'s measured rate.',
|
|
544
|
+
allLabelsUnmapped: (label, usd) =>
|
|
545
|
+
`${label} carries ${usd} of measured spend and no prompt file is mapped to it — the workload nobody can optimise because nobody said where it lives. Map it under "labels" in trazum.config.json.`,
|
|
546
|
+
allLabelsDead: (label, path) =>
|
|
547
|
+
`${label} is mapped to ${path} and has no traffic in this log — a retired workload, a renamed label, or a typo that has been silently doing nothing.`,
|
|
548
|
+
allLabelsUnreadable: (label, path) =>
|
|
549
|
+
`${label} is mapped to ${path}, which could not be read. The mapping exists; the file does not.`,
|
|
550
|
+
usageLineMeasured: (calls, days, scaled, outputTokens, batch) =>
|
|
551
|
+
`${calls} calls measured over ${days} days — ${scaled}/month at that rate · ${outputTokens} output tokens per call, measured${batch ? ' · Batch API' : ''}`,
|
|
552
|
+
usageLineMeasuredPeriod: (calls, days, outputTokens, batch) =>
|
|
553
|
+
`${calls} calls measured${days === null ? ' (the log carries no clock)' : ` over ${days} days`} · ${outputTokens} output tokens per call, measured${batch ? ' · Batch API' : ''}`,
|
|
554
|
+
measuredModelShare: (model, share, count) =>
|
|
555
|
+
`This label ran on ${count} models; the figures use ${model}, which carried ${share} of its spend.`,
|
|
556
|
+
measuredNoOutput: () =>
|
|
557
|
+
'No call in this slice recorded output tokens, so the output half of every figure below is $0 measured — not $0 assumed.',
|
|
558
|
+
perPeriodSaving: (saving, pct) => `saving ${saving} over the measured period (${pct}%)`,
|
|
559
|
+
periodNotScaled: (days) =>
|
|
560
|
+
days === null
|
|
561
|
+
? 'Not scaled to a month: the log carries no clock, so there is no rate to scale. These figures cover exactly the calls measured.'
|
|
562
|
+
: `Not scaled to a month: ${days} days is under the week a scaling needs — shorter than one weekly cycle multiplies whichever part of the cycle it caught. These figures cover exactly the period measured.`,
|
|
513
563
|
perMonthSaving: (saving, pct) => `saving ${saving}/month (${pct}%)`,
|
|
514
564
|
beyondShortening: () => 'Beyond shortening the prompt',
|
|
515
565
|
biggestLever: () => 'Start here:',
|
package/src/i18n/es.ts
CHANGED
|
@@ -109,6 +109,20 @@ ${bold('OPCIONES DE optimize')}
|
|
|
109
109
|
--calls <n> Llamadas al mes. Por defecto: ${d.callsPerMonth}.
|
|
110
110
|
--output-tokens <n> Tokens de salida medios. Por defecto: ${d.avgOutputTokens}.
|
|
111
111
|
--cache-hit-rate <0-1> Tasa de acierto de caché estimada. Por defecto: ${d.cacheHitRate}.
|
|
112
|
+
--all-labels Con --from-log: cada prompt del mapa "labels"
|
|
113
|
+
de la config, optimizado y valorado contra su
|
|
114
|
+
propio tráfico medido, ordenado por lo que vale
|
|
115
|
+
el cambio — más los desajustes en ambos sentidos
|
|
116
|
+
(prompts mapeados sin tráfico, tráfico sin
|
|
117
|
+
prompt mapeado).
|
|
118
|
+
--from-log <usage.jsonl> Mide las tres cifras de arriba desde un registro
|
|
119
|
+
de uso en vez de teclearlas: llamadas reales,
|
|
120
|
+
tamaño de salida real, cuota de caché real y el
|
|
121
|
+
modelo al que fueron las llamadas. Rechaza los
|
|
122
|
+
flags tecleados a su lado, escala a un mes solo
|
|
123
|
+
con una semana completa de datos, y dice qué
|
|
124
|
+
cifras están medidas. Combínalo con --label, o
|
|
125
|
+
mapea el prompt en "labels" de trazum.config.json.
|
|
112
126
|
--batch El trabajo tolera latencia (Batch API, 50% menos).
|
|
113
127
|
--disable <id,id> Desactiva reglas concretas (ver "trazum rules").
|
|
114
128
|
--suggest Pide al LLM reescrituras a nivel de frase y las lista
|
|
@@ -451,6 +465,18 @@ ${bold('EJEMPLOS')}
|
|
|
451
465
|
mustBeNonNegative: (name, raw) =>
|
|
452
466
|
`--${name} debe ser un número no negativo (recibido: "${raw}").`,
|
|
453
467
|
badLevel: (received) => `--level debe ser "safe" o "aggressive" (recibido: "${received}").`,
|
|
468
|
+
allLabelsNeedsLog: () =>
|
|
469
|
+
'--all-labels ordena los prompts por tráfico medido, así que necesita --from-log <usage.jsonl>. Sin registro cada ahorro se multiplicaría por la misma suposición tecleada, lo que ordena los prompts por longitud y lo llama prioridad.',
|
|
470
|
+
allLabelsNeedsMap: () =>
|
|
471
|
+
'--all-labels lee el mapa "labels" de trazum.config.json — etiqueta a fichero de prompt — y esta config no tiene ninguno. Mapea al menos una carga a su prompt.',
|
|
472
|
+
fromLogConflict: (flag) =>
|
|
473
|
+
`--from-log mide la cifra que --${flag} teclea, y mezclar una medición con una suposición produce un número que no es ninguna de las dos. Pasa una u otra.`,
|
|
474
|
+
fromLogNeedsLabel: (available) =>
|
|
475
|
+
`--from-log necesita saber qué carga es este prompt: pasa --label, o mapea el fichero bajo "labels" en trazum.config.json. Etiquetas con tráfico en este registro: ${available}.`,
|
|
476
|
+
fromLogAmbiguousLabel: (target, labels) =>
|
|
477
|
+
`${target} está mapeado a más de una etiqueta en trazum.config.json (${labels}), así que --from-log no puede elegir una en silencio. Pasa --label.`,
|
|
478
|
+
fromLogLabelEmpty: (label, available) =>
|
|
479
|
+
`Ninguna llamada valorada de este registro lleva la etiqueta "${label}", así que no hay nada que medir — un perfil de cero llamadas valoraría este cambio como inútil en vez de como no medido. Etiquetas con tráfico: ${available}.`,
|
|
454
480
|
unknownRuleInDisable: (id) =>
|
|
455
481
|
`Regla desconocida en --disable: "${id}". Lista completa: trazum rules`,
|
|
456
482
|
unknownCommand: (command) => `Comando desconocido: "${command}". Prueba con "trazum --help".`,
|
|
@@ -519,6 +545,30 @@ ${bold('EJEMPLOS')}
|
|
|
519
545
|
`${calls} llamadas/mes · ${outputTokens} tokens de salida por llamada${
|
|
520
546
|
batch ? ' · Batch API' : ''
|
|
521
547
|
}`,
|
|
548
|
+
allLabelsHeading: (count) => `Cada prompt mapeado contra su propio tráfico medido — ${count} ordenados por lo que vale el cambio`,
|
|
549
|
+
allLabelsRow: (saving) => `${saving}/mes si se optimiza`,
|
|
550
|
+
allLabelsRowPeriod: (saving) => `${saving} en el periodo medido si se optimiza`,
|
|
551
|
+
allLabelsFooter: () =>
|
|
552
|
+
'Ordenado por tráfico medido, no por longitud del prompt: un prompt grande en una carga muerta vale menos que uno pequeño en una ocupada. Cada cifra es el delta de tokens de este prompt al ritmo medido de su propia etiqueta.',
|
|
553
|
+
allLabelsUnmapped: (label, usd) =>
|
|
554
|
+
`${label} lleva ${usd} de gasto medido y ningún fichero de prompt está mapeado a ella — la carga que nadie puede optimizar porque nadie dijo dónde vive. Mapéala en "labels" de trazum.config.json.`,
|
|
555
|
+
allLabelsDead: (label, path) =>
|
|
556
|
+
`${label} está mapeada a ${path} y no tiene tráfico en este registro — una carga retirada, una etiqueta renombrada, o una errata que lleva sin hacer nada en silencio.`,
|
|
557
|
+
allLabelsUnreadable: (label, path) =>
|
|
558
|
+
`${label} está mapeada a ${path}, que no se pudo leer. El mapeo existe; el fichero no.`,
|
|
559
|
+
usageLineMeasured: (calls, days, scaled, outputTokens, batch) =>
|
|
560
|
+
`${calls} llamadas medidas en ${days} días — ${scaled}/mes a ese ritmo · ${outputTokens} tokens de salida por llamada, medidos${batch ? ' · Batch API' : ''}`,
|
|
561
|
+
usageLineMeasuredPeriod: (calls, days, outputTokens, batch) =>
|
|
562
|
+
`${calls} llamadas medidas${days === null ? ' (el registro no tiene reloj)' : ` en ${days} días`} · ${outputTokens} tokens de salida por llamada, medidos${batch ? ' · Batch API' : ''}`,
|
|
563
|
+
measuredModelShare: (model, share, count) =>
|
|
564
|
+
`Esta etiqueta corrió en ${count} modelos; las cifras usan ${model}, que llevó el ${share} de su gasto.`,
|
|
565
|
+
measuredNoOutput: () =>
|
|
566
|
+
'Ninguna llamada de este corte registró tokens de salida, así que la mitad de salida de cada cifra de abajo es $0 medido — no $0 supuesto.',
|
|
567
|
+
perPeriodSaving: (saving, pct) => `ahorro de ${saving} en el periodo medido (${pct}%)`,
|
|
568
|
+
periodNotScaled: (days) =>
|
|
569
|
+
days === null
|
|
570
|
+
? 'Sin escalar a un mes: el registro no tiene reloj, así que no hay ritmo que escalar. Estas cifras cubren exactamente las llamadas medidas.'
|
|
571
|
+
: `Sin escalar a un mes: ${days} días queda por debajo de la semana que un escalado necesita — menos de un ciclo semanal multiplica la parte del ciclo que pilló. Estas cifras cubren exactamente el periodo medido.`,
|
|
522
572
|
perMonthSaving: (saving, pct) => `ahorro ${saving}/mes (${pct}%)`,
|
|
523
573
|
beyondShortening: () => 'Además de acortar el prompt',
|
|
524
574
|
biggestLever: () => 'Empieza por aquí:',
|
package/src/i18n/types.ts
CHANGED
|
@@ -37,6 +37,13 @@ export interface CliMessages {
|
|
|
37
37
|
optionNeedsValue(name: string): string;
|
|
38
38
|
mustBeNonNegative(name: string, raw: string): string;
|
|
39
39
|
badLevel(received: string): string;
|
|
40
|
+
/** `--from-log`'s refusals: contradiction, missing label, ambiguity, emptiness. */
|
|
41
|
+
allLabelsNeedsLog(): string;
|
|
42
|
+
allLabelsNeedsMap(): string;
|
|
43
|
+
fromLogConflict(flag: string): string;
|
|
44
|
+
fromLogNeedsLabel(available: string): string;
|
|
45
|
+
fromLogAmbiguousLabel(target: string, labels: string): string;
|
|
46
|
+
fromLogLabelEmpty(label: string, available: string): string;
|
|
40
47
|
unknownRuleInDisable(id: string): string;
|
|
41
48
|
unknownCommand(command: string): string;
|
|
42
49
|
unknownFlag(name: string, allowed: string): string;
|
|
@@ -82,6 +89,28 @@ export interface CliMessages {
|
|
|
82
89
|
llmRejected(reason: string): string;
|
|
83
90
|
costWith(modelName: string): string;
|
|
84
91
|
usageLine(calls: string, outputTokens: number, batch: boolean): string;
|
|
92
|
+
/**
|
|
93
|
+
* `--from-log`: the usage line names its provenance. Measured and typed
|
|
94
|
+
* are different claims about the same multiplication, and under the week
|
|
95
|
+
* floor nothing says "month".
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* `--all-labels`: every mapped prompt against its own measured traffic,
|
|
99
|
+
* ranked by what the change is worth, with both coverage mismatches named.
|
|
100
|
+
*/
|
|
101
|
+
allLabelsHeading(count: string): string;
|
|
102
|
+
allLabelsRow(saving: string): string;
|
|
103
|
+
allLabelsRowPeriod(saving: string): string;
|
|
104
|
+
allLabelsFooter(): string;
|
|
105
|
+
allLabelsUnmapped(label: string, usd: string): string;
|
|
106
|
+
allLabelsDead(label: string, path: string): string;
|
|
107
|
+
allLabelsUnreadable(label: string, path: string): string;
|
|
108
|
+
usageLineMeasured(calls: string, days: string, scaled: string, outputTokens: number, batch: boolean): string;
|
|
109
|
+
usageLineMeasuredPeriod(calls: string, days: string | null, outputTokens: number, batch: boolean): string;
|
|
110
|
+
measuredModelShare(model: string, share: string, count: string): string;
|
|
111
|
+
measuredNoOutput(): string;
|
|
112
|
+
perPeriodSaving(saving: string, pct: string): string;
|
|
113
|
+
periodNotScaled(days: string | null): string;
|
|
85
114
|
perMonthSaving(saving: string, pct: string): string;
|
|
86
115
|
beyondShortening(): string;
|
|
87
116
|
biggestLever(): string;
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
coverageDrift,
|
|
24
24
|
driversBetween,
|
|
25
25
|
explainGateFailure,
|
|
26
|
+
labelCoverage,
|
|
27
|
+
measuredUsage,
|
|
26
28
|
gateMargin,
|
|
27
29
|
GATE_MARGIN_TIGHT,
|
|
28
30
|
estimateTokens,
|
|
@@ -72,6 +74,7 @@ import {
|
|
|
72
74
|
import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
|
|
73
75
|
import { dayOf, formatGap, median, spanDays } from './time.js';
|
|
74
76
|
import type {
|
|
77
|
+
MeasuredUsage,
|
|
75
78
|
BaselineBreach,
|
|
76
79
|
BaselineChange,
|
|
77
80
|
BaselineComparison,
|
|
@@ -165,6 +168,7 @@ interface Args {
|
|
|
165
168
|
|
|
166
169
|
const VALUE_FLAGS = new Set([
|
|
167
170
|
'against',
|
|
171
|
+
'from-log',
|
|
168
172
|
// `route` takes a path here, and the flag is deliberately not `--prompt`:
|
|
169
173
|
// everywhere else in this tool `--prompt` names a marked prompt *inside* a
|
|
170
174
|
// source file, and reusing it for a path would be a trap laid for the reader.
|
|
@@ -305,6 +309,23 @@ function levelFlag(args: Args, config: TrazumConfig, t: CliMessages): RuleLevel
|
|
|
305
309
|
* model id. It beats the default because reading the code is better than
|
|
306
310
|
* assuming, and loses to config because being told is better than reading.
|
|
307
311
|
*/
|
|
312
|
+
/**
|
|
313
|
+
* One usage log, gzip included, shared by every command that reads one.
|
|
314
|
+
*
|
|
315
|
+
* A `.gz` that will not decompress is an error naming the file — skipping it
|
|
316
|
+
* would be a figure quietly missing a day, the failure this repository
|
|
317
|
+
* refuses everywhere it can occur.
|
|
318
|
+
*/
|
|
319
|
+
async function readUsageLog(file: string, t: CliMessages): Promise<string> {
|
|
320
|
+
if (!file.endsWith('.gz')) return readFile(file, 'utf8');
|
|
321
|
+
const compressed = await readFile(file);
|
|
322
|
+
try {
|
|
323
|
+
return gunzipSync(compressed).toString('utf8');
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw new Error(t.profile.badGzip(file, error instanceof Error ? error.message : String(error)));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
308
329
|
function usageFrom(
|
|
309
330
|
args: Args,
|
|
310
331
|
config: TrazumConfig,
|
|
@@ -436,7 +457,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
436
457
|
'level', 'model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch',
|
|
437
458
|
'disable', 'llm', 'exact-tokens', 'diff', 'reorder', 'out', 'o',
|
|
438
459
|
'tokens-only', 'cost', 'prompt', 'suggest', 'apply-suggestions',
|
|
439
|
-
'cache-suggestions',
|
|
460
|
+
'cache-suggestions', 'from-log', 'label', 'all-labels',
|
|
440
461
|
],
|
|
441
462
|
check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
|
|
442
463
|
baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
|
|
@@ -629,6 +650,8 @@ function printReport(
|
|
|
629
650
|
suggestions: { result: SuggestResult; applied: boolean; locale: Locale } | null = null,
|
|
630
651
|
/** They named a scenario, and the host is suppressing the money anyway. */
|
|
631
652
|
namedScenario = false,
|
|
653
|
+
/** Present when the usage came from a log rather than from typing. */
|
|
654
|
+
measured: MeasuredUsage | null = null,
|
|
632
655
|
): void {
|
|
633
656
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
634
657
|
const sourceNote =
|
|
@@ -776,7 +799,7 @@ function printReport(
|
|
|
776
799
|
if (tokensOnly) {
|
|
777
800
|
printTokensOnly(result, host, t, n, namedScenario);
|
|
778
801
|
} else {
|
|
779
|
-
printMoney(result, t, n);
|
|
802
|
+
printMoney(result, t, n, measured);
|
|
780
803
|
}
|
|
781
804
|
|
|
782
805
|
// On a subscription, an advisory whose entire pitch is money is not weaker
|
|
@@ -852,17 +875,61 @@ function printReport(
|
|
|
852
875
|
}
|
|
853
876
|
|
|
854
877
|
/** The cost section, for anyone billed by the token. */
|
|
855
|
-
function printMoney(
|
|
878
|
+
function printMoney(
|
|
879
|
+
result: OptimizationResult,
|
|
880
|
+
t: CliMessages,
|
|
881
|
+
n: (v: number) => string,
|
|
882
|
+
/** Present when the usage came from a log rather than from typing. */
|
|
883
|
+
measured: MeasuredUsage | null = null,
|
|
884
|
+
): void {
|
|
856
885
|
const { savings } = result;
|
|
857
886
|
console.log();
|
|
858
887
|
console.log(c.bold(t.report.costWith(savings.modelDisplayName)));
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
888
|
+
/**
|
|
889
|
+
* The usage line names its provenance. "1,000 calls/month" typed and
|
|
890
|
+
* "1,043 calls measured over 12 days, scaled" are different claims about
|
|
891
|
+
* the same multiplication, and the reader budgeting on the result must
|
|
892
|
+
* know which one they are holding. Under the week floor nothing is scaled
|
|
893
|
+
* and nothing says "month": the figures cover exactly the period measured.
|
|
894
|
+
*/
|
|
895
|
+
if (measured !== null) {
|
|
896
|
+
if (measured.scaled !== null) {
|
|
897
|
+
console.log(
|
|
898
|
+
` ${t.report.usageLineMeasured(
|
|
899
|
+
n(measured.calls),
|
|
900
|
+
measured.scaled.fromDays.toFixed(1),
|
|
901
|
+
n(result.usage.callsPerMonth),
|
|
902
|
+
result.usage.avgOutputTokens,
|
|
903
|
+
result.usage.batchEligible,
|
|
904
|
+
)}`,
|
|
905
|
+
);
|
|
906
|
+
} else {
|
|
907
|
+
console.log(
|
|
908
|
+
` ${t.report.usageLineMeasuredPeriod(
|
|
909
|
+
n(measured.calls),
|
|
910
|
+
measured.spanDays === null ? null : measured.spanDays.toFixed(1),
|
|
911
|
+
result.usage.avgOutputTokens,
|
|
912
|
+
result.usage.batchEligible,
|
|
913
|
+
)}`,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
if (measured.models.count > 1) {
|
|
917
|
+
console.log(
|
|
918
|
+
` ${c.dim(wrap(t.report.measuredModelShare(measured.models.chosen, `${(measured.models.chosenShareOfSpend * 100).toFixed(0)}%`, n(measured.models.count)), 74, ' '))}`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (measured.outputUnmeasured) {
|
|
922
|
+
console.log(` ${c.dim(wrap(t.report.measuredNoOutput(), 74, ' '))}`);
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
console.log(
|
|
926
|
+
` ${t.report.usageLine(
|
|
927
|
+
n(result.usage.callsPerMonth),
|
|
928
|
+
result.usage.avgOutputTokens,
|
|
929
|
+
result.usage.batchEligible,
|
|
930
|
+
)}`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
866
933
|
// Said, not assumed. Once prices can be overlaid locally, a figure from the
|
|
867
934
|
// bundled catalogue and a figure from somebody's JSON file look identical, and
|
|
868
935
|
// the reader has to be able to tell which one they are about to budget against.
|
|
@@ -875,17 +942,27 @@ function printMoney(result: OptimizationResult, t: CliMessages, n: (v: number) =
|
|
|
875
942
|
` ${c.yellow(t.report.pricingOverlaid(touched.join(', '), result.pricingSource.lastReviewed))}`,
|
|
876
943
|
);
|
|
877
944
|
}
|
|
945
|
+
const periodOnly = measured !== null && measured.scaled === null;
|
|
878
946
|
console.log(
|
|
879
947
|
` ${formatUsd(savings.perMonth.before.totalUsd)} → ` +
|
|
880
948
|
`${c.green(formatUsd(savings.perMonth.after.totalUsd))} ` +
|
|
881
949
|
c.bold(
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
950
|
+
periodOnly
|
|
951
|
+
? t.report.perPeriodSaving(
|
|
952
|
+
formatUsd(savings.monthlySavingsUsd),
|
|
953
|
+
savings.monthlySavingsPct.toFixed(1),
|
|
954
|
+
)
|
|
955
|
+
: t.report.perMonthSaving(
|
|
956
|
+
formatUsd(savings.monthlySavingsUsd),
|
|
957
|
+
savings.monthlySavingsPct.toFixed(1),
|
|
958
|
+
),
|
|
886
959
|
),
|
|
887
960
|
);
|
|
888
|
-
|
|
961
|
+
if (periodOnly) {
|
|
962
|
+
console.log(
|
|
963
|
+
` ${c.dim(wrap(t.report.periodNotScaled(measured!.spanDays === null ? null : measured!.spanDays.toFixed(1)), 74, ' '))}`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
889
966
|
}
|
|
890
967
|
|
|
891
968
|
/**
|
|
@@ -1372,6 +1449,97 @@ async function commandOptimize(
|
|
|
1372
1449
|
t: CliMessages,
|
|
1373
1450
|
locale: Locale,
|
|
1374
1451
|
): Promise<void> {
|
|
1452
|
+
/**
|
|
1453
|
+
* `--all-labels`: every mapped prompt against its own measured traffic,
|
|
1454
|
+
* ranked by what the change is worth — the list a person actually wants,
|
|
1455
|
+
* which is "which prompt do I edit first".
|
|
1456
|
+
*
|
|
1457
|
+
* Requires `--from-log`, because ranking estimated savings that were all
|
|
1458
|
+
* multiplied by the same typed guess ranks the prompts by length, and calls
|
|
1459
|
+
* that a priority. And it renders both coverage mismatches at the end: a
|
|
1460
|
+
* prompt mapped to a label with no traffic is dead weight or a rename, and
|
|
1461
|
+
* a label carrying real money with no prompt mapped is the workload nobody
|
|
1462
|
+
* can optimise because nobody said where it lives.
|
|
1463
|
+
*/
|
|
1464
|
+
if (boolFlag(args, 'all-labels')) {
|
|
1465
|
+
const fromLogPath = stringFlag(args, 'from-log');
|
|
1466
|
+
if (fromLogPath === undefined) throw new Error(t.errors.allLabelsNeedsLog());
|
|
1467
|
+
const labelsMap = config.labels ?? {};
|
|
1468
|
+
if (Object.keys(labelsMap).length === 0) throw new Error(t.errors.allLabelsNeedsMap());
|
|
1469
|
+
const report = profileUsage(await readUsageLog(fromLogPath, t), { catalogue: pricing });
|
|
1470
|
+
const coverage = labelCoverage(report, labelsMap);
|
|
1471
|
+
const level = levelFlag(args, config, t);
|
|
1472
|
+
|
|
1473
|
+
interface Row {
|
|
1474
|
+
label: string;
|
|
1475
|
+
path: string;
|
|
1476
|
+
tokensBefore: number;
|
|
1477
|
+
tokensAfter: number;
|
|
1478
|
+
savingUsd: number;
|
|
1479
|
+
periodOnly: boolean;
|
|
1480
|
+
spentUsd: number;
|
|
1481
|
+
}
|
|
1482
|
+
const rows: Row[] = [];
|
|
1483
|
+
const unreadable: { label: string; path: string }[] = [];
|
|
1484
|
+
for (const { label, promptPath } of coverage.joined) {
|
|
1485
|
+
const m = measuredUsage(report, label, { batchEligible: config.usage?.batchEligible ?? false });
|
|
1486
|
+
if (m === null) continue;
|
|
1487
|
+
let text: string;
|
|
1488
|
+
try {
|
|
1489
|
+
text = await readFile(promptPath, 'utf8');
|
|
1490
|
+
} catch {
|
|
1491
|
+
unreadable.push({ label, path: promptPath });
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
const r = optimize(text, { level, usage: m.profile, locale, pricing });
|
|
1495
|
+
rows.push({
|
|
1496
|
+
label,
|
|
1497
|
+
path: promptPath,
|
|
1498
|
+
tokensBefore: r.tokensBefore,
|
|
1499
|
+
tokensAfter: r.tokensAfter,
|
|
1500
|
+
savingUsd: r.savings.monthlySavingsUsd,
|
|
1501
|
+
periodOnly: m.scaled === null,
|
|
1502
|
+
spentUsd: m.spentUsd,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
rows.sort((a, b) => b.savingUsd - a.savingUsd);
|
|
1506
|
+
|
|
1507
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
1508
|
+
console.log(c.bold(t.report.allLabelsHeading(n(rows.length))));
|
|
1509
|
+
for (const row of rows) {
|
|
1510
|
+
const saving = row.periodOnly
|
|
1511
|
+
? t.report.allLabelsRowPeriod(formatUsd(row.savingUsd))
|
|
1512
|
+
: t.report.allLabelsRow(formatUsd(row.savingUsd));
|
|
1513
|
+
console.log(
|
|
1514
|
+
` ${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`)}`,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
if (rows.length > 0) {
|
|
1518
|
+
console.log(` ${c.dim(wrap(t.report.allLabelsFooter(), 74, ' '))}`);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The mismatches, both directions, never silently. These are the two
|
|
1523
|
+
* failures neither side can see alone.
|
|
1524
|
+
*/
|
|
1525
|
+
for (const gap of coverage.trafficWithoutPrompt.slice(0, 5)) {
|
|
1526
|
+
console.log(
|
|
1527
|
+
` ${c.yellow('!')} ${wrap(t.report.allLabelsUnmapped(gap.label, formatUsd(gap.spentUsd)), 74, ' ')}`,
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
for (const dead of coverage.mappedWithoutTraffic) {
|
|
1531
|
+
console.log(
|
|
1532
|
+
` ${c.dim(wrap(t.report.allLabelsDead(dead.label, dead.promptPath), 74, ' '))}`,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
for (const miss of unreadable) {
|
|
1536
|
+
console.log(
|
|
1537
|
+
` ${c.yellow('!')} ${wrap(t.report.allLabelsUnreadable(miss.label, miss.path), 74, ' ')}`,
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1375
1543
|
const target = args.positional[0];
|
|
1376
1544
|
const raw = await readInput(target, t);
|
|
1377
1545
|
const level = levelFlag(args, config, t);
|
|
@@ -1393,7 +1561,61 @@ async function commandOptimize(
|
|
|
1393
1561
|
// Detection sits between config and defaults, as everywhere: a flag beats
|
|
1394
1562
|
// config, config beats what the code says, and what the code says beats a
|
|
1395
1563
|
// built-in default that has no idea which provider you use.
|
|
1396
|
-
|
|
1564
|
+
let usage = usageFrom(args, config, t, source?.model);
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* `--from-log`: the multiplication stops guessing.
|
|
1568
|
+
*
|
|
1569
|
+
* The saving printed below is `token delta × usage`, and until now every
|
|
1570
|
+
* part of `usage` was typed by a human. A usage log knows the real call
|
|
1571
|
+
* count, the real output size, the real cache share and the model the
|
|
1572
|
+
* calls actually went to — so `--from-log` measures them, and the typed
|
|
1573
|
+
* flags are refused beside it rather than merged: measuring and typing the
|
|
1574
|
+
* same figure is a contradiction, not a preference order.
|
|
1575
|
+
*/
|
|
1576
|
+
const fromLog = stringFlag(args, 'from-log');
|
|
1577
|
+
let measured: MeasuredUsage | null = null;
|
|
1578
|
+
if (fromLog !== undefined) {
|
|
1579
|
+
for (const flag of ['calls', 'output-tokens', 'cache-hit-rate', 'model']) {
|
|
1580
|
+
if (args.flags.get(flag) !== undefined) {
|
|
1581
|
+
throw new Error(t.errors.fromLogConflict(flag));
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const report = profileUsage(await readUsageLog(fromLog, t), { catalogue: pricing });
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Which label this prompt is. `--label` says it outright; otherwise the
|
|
1588
|
+
* config's `labels` map is read in reverse — it maps labels to prompt
|
|
1589
|
+
* files, and the file on the command line is looked up among its values.
|
|
1590
|
+
* Ambiguity (two labels mapped to one file) is an error naming both,
|
|
1591
|
+
* never a silent first match.
|
|
1592
|
+
*/
|
|
1593
|
+
let label = stringFlag(args, 'label');
|
|
1594
|
+
if (label === undefined && target !== undefined && config.labels !== undefined) {
|
|
1595
|
+
const hits = Object.entries(config.labels)
|
|
1596
|
+
.filter(([, path]) => resolvePath(path) === resolvePath(target))
|
|
1597
|
+
.map(([name]) => name);
|
|
1598
|
+
if (hits.length > 1) throw new Error(t.errors.fromLogAmbiguousLabel(target, hits.join(', ')));
|
|
1599
|
+
label = hits[0];
|
|
1600
|
+
}
|
|
1601
|
+
if (label === undefined) {
|
|
1602
|
+
const available = report.byLabel
|
|
1603
|
+
.map((row) => (row.label === UNLABELLED ? t.profile.unlabelled() : row.label))
|
|
1604
|
+
.join(', ');
|
|
1605
|
+
throw new Error(t.errors.fromLogNeedsLabel(available || '—'));
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
measured = measuredUsage(report, label, {
|
|
1609
|
+
batchEligible: boolFlag(args, 'batch', config.usage?.batchEligible ?? false),
|
|
1610
|
+
});
|
|
1611
|
+
if (measured === null) {
|
|
1612
|
+
const available = report.byLabel
|
|
1613
|
+
.map((row) => (row.label === UNLABELLED ? t.profile.unlabelled() : row.label))
|
|
1614
|
+
.join(', ');
|
|
1615
|
+
throw new Error(t.errors.fromLogLabelEmpty(label, available || '—'));
|
|
1616
|
+
}
|
|
1617
|
+
usage = measured.profile;
|
|
1618
|
+
}
|
|
1397
1619
|
|
|
1398
1620
|
const disableRules = disabledRules(args, config) ?? [];
|
|
1399
1621
|
for (const id of disableRules) {
|
|
@@ -1586,7 +1808,15 @@ async function commandOptimize(
|
|
|
1586
1808
|
// Cursor wants the dollars, and they should not have to leave the editor to
|
|
1587
1809
|
// see them.
|
|
1588
1810
|
const host = detectHost();
|
|
1589
|
-
|
|
1811
|
+
/**
|
|
1812
|
+
* `--from-log` implies `--cost`, and the reasoning is different from the
|
|
1813
|
+
* `--calls` case documented below: `--calls` is a typed scenario parameter,
|
|
1814
|
+
* but a usage log with billed token counts is *evidence* — proof this
|
|
1815
|
+
* prompt's traffic goes to a metered API, whatever the terminal running
|
|
1816
|
+
* the command bills like. Withholding the money there would suppress
|
|
1817
|
+
* exactly the figures the person measured in order to see.
|
|
1818
|
+
*/
|
|
1819
|
+
const tokensOnly = boolFlag(args, 'cost') || measured !== null
|
|
1590
1820
|
? false
|
|
1591
1821
|
: boolFlag(args, 'tokens-only') || host.billing === 'subscription';
|
|
1592
1822
|
/**
|
|
@@ -1609,6 +1839,7 @@ async function commandOptimize(
|
|
|
1609
1839
|
? { result: suggestions, applied: boolFlag(args, 'apply-suggestions'), locale }
|
|
1610
1840
|
: null,
|
|
1611
1841
|
namedScenario,
|
|
1842
|
+
measured,
|
|
1612
1843
|
);
|
|
1613
1844
|
if (outPath) {
|
|
1614
1845
|
console.log(c.dim(t.report.wroteTo(outPath)));
|
|
@@ -2031,16 +2262,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2031
2262
|
* alternative — skipping it — is a total quietly missing a day, which is
|
|
2032
2263
|
* the failure this repository refuses in every other place it can occur.
|
|
2033
2264
|
*/
|
|
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)));
|
|
2265
|
+
const logTexts = await Promise.all(logFiles.map((file) => readUsageLog(file, t)));
|
|
2044
2266
|
// A file that does not end in a newline would otherwise glue its last record
|
|
2045
2267
|
// to the next file's first one, and both would be reported as unreadable.
|
|
2046
2268
|
const raw = logTexts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
|
|
@@ -2206,7 +2428,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2206
2428
|
// The same reader as the log itself, so `--against last-month.jsonl.gz`
|
|
2207
2429
|
// works: a comparison that could only read one of the two formats would
|
|
2208
2430
|
// be a flag that fails on exactly the rotated file it exists to read.
|
|
2209
|
-
? profileUsage(await
|
|
2431
|
+
? profileUsage(await readUsageLog(againstPath, t), {
|
|
2210
2432
|
catalogue: pricing,
|
|
2211
2433
|
label: onlyLabel,
|
|
2212
2434
|
// The same window on both sides, for the same reason as the label:
|