@trazum/cli 1.28.0 → 1.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.28.0",
3
+ "version": "1.30.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.28.0"
40
+ "@trazum/core": "1.30.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -234,6 +234,10 @@ ${bold('OPTIONS FOR profile')}
234
234
  afternoon a loop burned a quarter of it. A log
235
235
  with no timestamps fails: not measured is not
236
236
  under budget.
237
+ --max-session-usd <n> Fail when any single conversation in the log
238
+ cost more than this — the unit an agent product
239
+ blows up in. A log with no sessions fails: not
240
+ measured is not under budget.
237
241
  --since <when> Profile only calls at or after this moment. A UTC
238
242
  --until <when> day (2026-08-14), a full ISO 8601 timestamp, a
239
243
  relative window (7d, 24h) or "now";
@@ -1081,6 +1085,12 @@ ${bold('EXAMPLES')}
1081
1085
  `FAILED — ${day} spent ${usd}, over the --max-day-usd limit of ${max}. A total under budget can hide a single runaway day, which is what this gate exists to catch.`,
1082
1086
  maxDayNoClock: () =>
1083
1087
  'FAILED — --max-day-usd was asked for and no record in this log carries a timestamp, so there are no days to judge. That is not a pass: a bill nobody could measure by day is not a bill that stayed under a daily budget. Add "ts" to the record and the gate arms.',
1088
+ maxSessionOk: (worst, max, sessions) =>
1089
+ `No conversation over budget: the most expensive of ${sessions} cost ${worst}, against --max-session-usd ${max}. A conversation that started before this log is counted only for the turns recorded here, so this is a floor.`,
1090
+ maxSessionFailed: (worst, max, sessions) =>
1091
+ `FAILED — the most expensive of ${sessions} conversations cost ${worst}, over the --max-session-usd limit of ${max}. A month's budget and a day's budget both pass while one conversation loops its way through this; the per-conversation figure is the one that catches it.`,
1092
+ maxSessionNoSessions: () =>
1093
+ 'FAILED — --max-session-usd was asked for and no record in this log carries a session, so there are no conversations to judge. That is not a pass. Add "session" (or "conversation_id") to the record and the gate arms; Trazum groups by it and never prints it.',
1084
1094
  maxDayUndated: (calls) =>
1085
1095
  `${calls} calls carry no timestamp, so they are in the bill and in none of the days above — the worst day is a floor by whatever they held. A failure would stand regardless; this pass is over the part that could be dated.`,
1086
1096
  maxCacheLossWorstCase: (calls, worst, max) =>
@@ -1161,6 +1171,23 @@ ${bold('EXAMPLES')}
1161
1171
  `No usage logs in "${directory}". Looked for files ending in ${extensions}. A directory with nothing readable in it is an error rather than an empty report, which would read as "you spent nothing".`,
1162
1172
  sessionCostTail: (ratio) =>
1163
1173
  `The 95th percentile is ${ratio}x the median there: most conversations are cheap and a few are not, which is a tail a quota can catch. Where median and p95 sit close together the workload is simply expensive and there is no tail to hunt.`,
1174
+ sessionSpendOnly: (sessions, max) =>
1175
+ `${sessions} ${sessions === '1' ? 'conversation' : 'conversations'} in this log; the most expensive cost ${max}. Too few per workload for a percentile — a maximum is a fact at any count, and it is the figure --max-session-usd judges.`,
1176
+ maxGrowthCoverageLost: (fields, was, now) =>
1177
+ `FAILED — this log stopped recording ${fields} (${was} of records before, ${now} now), so the comparison cannot be made. That is not a pass: a bill whose growth nobody could measure is not a bill that stayed flat, and every finding that needed the field went quiet for a reason that has nothing to do with spend.`,
1178
+ coverageField: (field) =>
1179
+ ({ label: 'label', session: 'session', ts: 'timestamp', stopReason: 'stop reason' })[field] ?? field,
1180
+ coverageSilenced: (field) =>
1181
+ ({
1182
+ label: 'Gone quiet with it: per-workload spend, the drill-down, and levers that describe a decision rather than a mixture.',
1183
+ session: 'Gone quiet with it: conversation growth, per-conversation cost, repeated turns, truncation retries and the cache-TTL fit.',
1184
+ ts: 'Gone quiet with it: the period, the per-day and per-hour shape, the model mix drift, and the cache-TTL question entirely.',
1185
+ stopReason: 'Gone quiet with it: answers cut off at max_tokens, and the retries billed after them.',
1186
+ })[field] ?? '',
1187
+ coverageDrift: (field, was, now) =>
1188
+ `Coverage moved: ${field} was on ${was} of records and is now on ${now}.`,
1189
+ coverageDriftWhy: () =>
1190
+ 'A field the log stopped recording is not a finding that got fixed — every finding that needed it has gone quiet for a reason that has nothing to do with the bill. Reported from a 20-point move in either direction; a field that appeared means this report can see what the previous one could not.',
1164
1191
  againstOverlap: (from, to) =>
1165
1192
  `These two logs both cover ${from} → ${to}, so some of the same calls sit on both sides of this subtraction and part of the change is the same money counted twice. Compare periods that do not overlap — or window both logs with --since/--until.`,
1166
1193
  windowLine: (since, until) =>
package/src/i18n/es.ts CHANGED
@@ -232,6 +232,10 @@ ${bold('OPCIONES DE profile')}
232
232
  una cuarta parte. Un registro sin marcas de
233
233
  tiempo falla: no medido no es dentro de
234
234
  presupuesto.
235
+ --max-session-usd <n> Falla cuando una sola conversación del registro
236
+ costó más que esto — la unidad en la que revienta
237
+ un producto de agentes. Un registro sin sesiones
238
+ falla: no medido no es dentro de presupuesto.
235
239
  --since <cuándo> Perfila solo llamadas desde/hasta ese momento. Un
236
240
  --until <cuándo> día UTC (2026-08-14), una marca ISO 8601 completa,
237
241
  una ventana relativa (7d, 24h) o "now";
@@ -1095,6 +1099,12 @@ ${bold('EJEMPLOS')}
1095
1099
  `FALLO — ${day} gastó ${usd}, por encima del límite --max-day-usd de ${max}. Un total dentro de presupuesto puede esconder un solo día desbocado, que es justo lo que vigila esta puerta.`,
1096
1100
  maxDayNoClock: () =>
1097
1101
  'FALLO — se pidió --max-day-usd y ningún registro de este fichero lleva marca de tiempo, así que no hay días que juzgar. Eso no es un aprobado: una factura que nadie pudo medir por días no es una factura que se mantuvo bajo un presupuesto diario. Añade "ts" al registro y la puerta se arma.',
1102
+ maxSessionOk: (worst, max, sessions) =>
1103
+ `Ninguna conversación por encima del presupuesto: la más cara de ${sessions} costó ${worst}, contra --max-session-usd ${max}. Una conversación que empezó antes de este registro solo cuenta los turnos grabados aquí, así que esto es un suelo.`,
1104
+ maxSessionFailed: (worst, max, sessions) =>
1105
+ `FALLO — la más cara de ${sessions} conversaciones costó ${worst}, por encima del límite --max-session-usd de ${max}. El presupuesto del mes y el del día aprueban mientras una conversación en bucle se come esto; la cifra por conversación es la que lo caza.`,
1106
+ maxSessionNoSessions: () =>
1107
+ 'FALLO — se pidió --max-session-usd y ningún registro lleva sesión, así que no hay conversaciones que juzgar. Eso no es un aprobado. Añade "session" (o "conversation_id") al registro y la puerta se arma; Trazum agrupa por ella y nunca la imprime.',
1098
1108
  maxDayUndated: (calls) =>
1099
1109
  `${calls} llamadas no llevan marca de tiempo, así que están en la factura y en ninguno de los días de arriba — el peor día es un suelo por lo que valieran esas llamadas. Un fallo se sostendría igual; este aprobado cubre la parte que se pudo fechar.`,
1100
1110
  maxCacheLossWorstCase: (calls, worst, max) =>
@@ -1176,6 +1186,23 @@ ${bold('EJEMPLOS')}
1176
1186
  `No hay registros de uso en "${directory}". Se buscaron ficheros terminados en ${extensions}. Un directorio sin nada legible es un error, no un informe vacío, que se leería como "no has gastado nada".`,
1177
1187
  sessionCostTail: (ratio) =>
1178
1188
  `El percentil 95 es ${ratio}x la mediana ahí: casi todas las conversaciones son baratas y unas pocas no, y esa es una cola que una cuota puede cazar. Cuando mediana y p95 quedan cerca, la carga es cara sin más y no hay cola que perseguir.`,
1189
+ sessionSpendOnly: (sessions, max) =>
1190
+ `${sessions} ${sessions === '1' ? 'conversación' : 'conversaciones'} en este registro; la más cara costó ${max}. Demasiado pocas por carga para un percentil — un máximo es un hecho con cualquier recuento, y es la cifra que juzga --max-session-usd.`,
1191
+ maxGrowthCoverageLost: (fields, was, now) =>
1192
+ `FALLÓ — este registro dejó de grabar ${fields} (${was} de los registros antes, ${now} ahora), así que la comparación no se puede hacer. Eso no es un aprobado: una factura cuyo crecimiento nadie pudo medir no es una factura que se mantuvo plana, y todo hallazgo que necesitaba ese campo se calló por un motivo que nada tiene que ver con el gasto.`,
1193
+ coverageField: (field) =>
1194
+ ({ label: 'etiqueta', session: 'sesión', ts: 'marca de tiempo', stopReason: 'razón de parada' })[field] ?? field,
1195
+ coverageSilenced: (field) =>
1196
+ ({
1197
+ label: 'Se callaron con él: el gasto por carga, el desglose y unas palancas que describan una decisión y no una mezcla.',
1198
+ session: 'Se callaron con él: el crecimiento de conversación, el coste por conversación, los turnos repetidos, los reintentos por truncado y el ajuste del TTL de caché.',
1199
+ ts: 'Se callaron con él: el periodo, la forma por día y por hora, la deriva de mezcla de modelos y la pregunta del TTL de caché por completo.',
1200
+ stopReason: 'Se callaron con él: las respuestas cortadas en max_tokens y los reintentos facturados después.',
1201
+ })[field] ?? '',
1202
+ coverageDrift: (field, was, now) =>
1203
+ `La cobertura se movió: ${field} estaba en el ${was} de los registros y ahora está en el ${now}.`,
1204
+ coverageDriftWhy: () =>
1205
+ 'Un campo que el registro dejó de grabar no es un hallazgo arreglado — todo hallazgo que lo necesitaba se ha callado por un motivo que nada tiene que ver con la factura. Se informa a partir de 20 puntos de movimiento en cualquier dirección; un campo que aparece significa que este informe ve lo que el anterior no podía.',
1179
1206
  againstOverlap: (from, to) =>
1180
1207
  `Estos dos registros cubren ambos ${from} → ${to}, así que algunas de las mismas llamadas están a los dos lados de esta resta y parte del cambio es el mismo dinero contado dos veces. Compara periodos que no se solapen — o acota ambos registros con --since/--until.`,
1181
1208
  windowLine: (since, until) =>
package/src/i18n/types.ts CHANGED
@@ -730,6 +730,15 @@ export interface CliMessages {
730
730
  maxDayFailed(day: string, usd: string, max: string): string;
731
731
  maxDayNoClock(): string;
732
732
  maxDayUndated(calls: string): string;
733
+ /**
734
+ * The per-conversation gate. The single most expensive conversation is
735
+ * the number a per-conversation policy judges; a log with no sessions
736
+ * fails, and a conversation that started before the log makes the pass
737
+ * a floor — said in the pass message. The session key never appears.
738
+ */
739
+ maxSessionOk(worst: string, max: string, sessions: string): string;
740
+ maxSessionFailed(worst: string, max: string, sessions: string): string;
741
+ maxSessionNoSessions(): string;
733
742
  /**
734
743
  * The price table behind every dollar in the report, when it is old
735
744
  * enough to matter. Unlike a skipped line, staleness does not name its
@@ -760,6 +769,26 @@ export interface CliMessages {
760
769
  * money on both sides of the subtraction. Only decidable when both logs
761
770
  * carry a clock; unknown stays silent rather than reassuring.
762
771
  */
772
+ /**
773
+ * What the comparison stopped being able to see. Dollars cannot tell a
774
+ * fixed finding from a field the log stopped recording; only coverage can,
775
+ * so the copy names the threshold and states the distinction.
776
+ */
777
+ /**
778
+ * The comparison gate's refusal: the current log stopped recording a field
779
+ * the previous one carried, so "did not grow" is a claim nobody could
780
+ * check. "Not measured" is not "did not grow", as everywhere here.
781
+ */
782
+ maxGrowthCoverageLost(fields: string, was: string, now: string): string;
783
+ coverageField(field: string): string;
784
+ /**
785
+ * Which findings a collapsed field took with it. Named rather than left as
786
+ * "some findings": a reader deciding whether to trust this report needs to
787
+ * know exactly which sections are now silence rather than absence.
788
+ */
789
+ coverageSilenced(field: string): string;
790
+ coverageDrift(field: string, was: string, now: string): string;
791
+ coverageDriftWhy(): string;
763
792
  againstOverlap(from: string, to: string): string;
764
793
  /**
765
794
  * What one conversation costs — median against p95, never a mean: one
@@ -777,6 +806,13 @@ export interface CliMessages {
777
806
  ): string;
778
807
  /** Said only when the p95 clears ten times the median — a real tail. */
779
808
  sessionCostTail(ratio: string): string;
809
+ /**
810
+ * The whole log's conversations when the per-slice percentiles refused:
811
+ * count and single worst cost, stated because a maximum is a fact at any
812
+ * count while a percentile over four conversations would be the largest
813
+ * of four wearing a percentile's name.
814
+ */
815
+ sessionSpendOnly(sessions: string, max: string): string;
780
816
  /**
781
817
  * Per-workload budgets from the config. A budgeted label with no calls in
782
818
  * the log is "not measured", never a pass: a workload that did not appear
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  countTokensAnthropic,
21
21
  DEFAULT_USAGE,
22
22
  detectFromSource,
23
+ coverageDrift,
23
24
  driversBetween,
24
25
  estimateTokens,
25
26
  evaluate,
@@ -180,6 +181,7 @@ const VALUE_FLAGS = new Set([
180
181
  'max-growth-usd',
181
182
  'max-cache-loss-usd',
182
183
  'max-day-usd',
184
+ 'max-session-usd',
183
185
  'csv-out',
184
186
  'csv-shape',
185
187
  'what-if',
@@ -435,7 +437,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
435
437
  ],
436
438
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
437
439
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
438
- profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'label', 'since', 'until'],
440
+ profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until'],
439
441
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
440
442
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
441
443
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2262,6 +2264,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2262
2264
  typeof args.flags.get('max-growth-usd') === 'string' ||
2263
2265
  typeof args.flags.get('max-cache-loss-usd') === 'string' ||
2264
2266
  typeof args.flags.get('max-day-usd') === 'string' ||
2267
+ typeof args.flags.get('max-session-usd') === 'string' ||
2265
2268
  config.spend !== undefined;
2266
2269
  if (anyGate) {
2267
2270
  const reasons: string[] = [];
@@ -2318,7 +2321,34 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2318
2321
  }
2319
2322
  if (typeof args.flags.get('max-growth-usd') === 'string' && againstDelta !== null) {
2320
2323
  const maxGrowth = numberFlag(args, 'max-growth-usd', 0, t);
2321
- if (againstDelta > maxGrowth) {
2324
+ /**
2325
+ * A comparison that went blind fails before it is judged.
2326
+ *
2327
+ * The dollars can hold flat while the current log stopped recording a
2328
+ * field the previous one carried — and every finding that needed the
2329
+ * field is now silent for a reason that has nothing to do with spend.
2330
+ * A gate passing there would be certifying a comparison it could not
2331
+ * make: "not measured" is not "did not grow", the same refusal
2332
+ * --max-day-usd makes on a clockless log and --max-session-usd on a
2333
+ * sessionless one. Only a collapse fails; a field that appeared means
2334
+ * this side can see more, which is never a reason to refuse.
2335
+ */
2336
+ const blinded = previous !== null
2337
+ ? coverageDrift(previous.fieldCoverage, report.fieldCoverage).filter((d) => d.delta < 0)
2338
+ : [];
2339
+ const worst = blinded[0];
2340
+ if (worst !== undefined) {
2341
+ console.error(
2342
+ c.red(
2343
+ t.profile.maxGrowthCoverageLost(
2344
+ blinded.map((d) => t.profile.coverageField(d.field)).join(', '),
2345
+ pct(worst.was),
2346
+ pct(worst.now),
2347
+ ),
2348
+ ),
2349
+ );
2350
+ process.exitCode = 1;
2351
+ } else if (againstDelta > maxGrowth) {
2322
2352
  console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
2323
2353
  process.exitCode = 1;
2324
2354
  }
@@ -2412,6 +2442,38 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2412
2442
  }
2413
2443
  }
2414
2444
  }
2445
+ /**
2446
+ * The per-conversation gate — the unit an agent product actually blows
2447
+ * up in. A month's budget and a day's budget both pass while one
2448
+ * conversation loops its way through $400; the single most expensive
2449
+ * conversation is the number a per-conversation policy has to judge,
2450
+ * and the log already carries it.
2451
+ *
2452
+ * The refusals it inherits: a log with **no sessions** fails rather
2453
+ * than passes ("not measured" is not "under budget"), and a
2454
+ * conversation that started before this log is counted only for the
2455
+ * turns recorded here — so a pass is a floor, and the pass message says
2456
+ * so. The session key itself is never printed, here or anywhere.
2457
+ */
2458
+ if (typeof args.flags.get('max-session-usd') === 'string' || config.spend?.maxSessionUsd !== undefined) {
2459
+ const maxSession =
2460
+ typeof args.flags.get('max-session-usd') === 'string'
2461
+ ? numberFlag(args, 'max-session-usd', 0, t)
2462
+ : config.spend!.maxSessionUsd!;
2463
+ if (report.sessionSpend === null) {
2464
+ console.error(c.red(t.profile.maxSessionNoSessions()));
2465
+ process.exitCode = 1;
2466
+ } else if (report.sessionSpend.maxUsd > maxSession) {
2467
+ console.error(
2468
+ c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
2469
+ );
2470
+ process.exitCode = 1;
2471
+ } else {
2472
+ console.error(
2473
+ c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
2474
+ );
2475
+ }
2476
+ }
2415
2477
  };
2416
2478
 
2417
2479
  /**
@@ -3070,6 +3132,19 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
3070
3132
  );
3071
3133
  }
3072
3134
  }
3135
+ /**
3136
+ * The figure that survives a small log. `sessionCosts` refuses slices too
3137
+ * thin for a percentile, and rightly — but a log of four conversations
3138
+ * still has a most expensive one, and that maximum is a fact at any count.
3139
+ * It is also exactly the number `--max-session-usd` judges, so the report
3140
+ * states it rather than going silent where the gate would speak.
3141
+ */
3142
+ if (report.sessionCosts.length === 0 && report.sessionSpend !== null) {
3143
+ console.log();
3144
+ console.log(
3145
+ ` ${c.dim(wrap(t.profile.sessionSpendOnly(n(report.sessionSpend.sessions), formatUsd(report.sessionSpend.maxUsd)), 74, ' '))}`,
3146
+ );
3147
+ }
3073
3148
 
3074
3149
  /**
3075
3150
  * A total that assumed a cache-write rate is a floor, and says so.
@@ -3596,6 +3671,46 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
3596
3671
  console.log(` ${d.delta > 0 ? c.yellow(line) : c.dim(line)}`);
3597
3672
  }
3598
3673
  }
3674
+
3675
+ /**
3676
+ * What the comparison stopped being able to see.
3677
+ *
3678
+ * Every figure above is dollars, and dollars cannot tell a finding that
3679
+ * was fixed from a finding whose field the log stopped recording — both
3680
+ * are silence. This is the only section that can, so it is loud: a
3681
+ * collapse in coverage invalidates whichever findings depended on it,
3682
+ * and reading the drop as good news is the specific mistake it exists
3683
+ * to prevent.
3684
+ */
3685
+ const drifts = coverageDrift(previous.fieldCoverage, report.fieldCoverage);
3686
+ if (drifts.length > 0) {
3687
+ console.log();
3688
+ for (const drift of drifts) {
3689
+ const line = t.profile.coverageDrift(
3690
+ t.profile.coverageField(drift.field),
3691
+ pct(drift.was),
3692
+ pct(drift.now),
3693
+ );
3694
+ console.log(
3695
+ drift.delta < 0
3696
+ ? ` ${c.yellow('!')} ${c.bold(wrap(line, 74, ' '))}`
3697
+ : ` ${c.dim(wrap(line, 74, ' '))}`,
3698
+ );
3699
+ /**
3700
+ * Which findings went with it, named. "Some findings are silent" is
3701
+ * not something a reader can act on; knowing that conversation
3702
+ * growth and the cache-TTL fit are now silence rather than absence
3703
+ * tells them exactly which sections of this report to distrust.
3704
+ */
3705
+ if (drift.delta < 0) {
3706
+ const silenced = t.profile.coverageSilenced(drift.field);
3707
+ if (silenced !== '') console.log(` ${c.dim(wrap(silenced, 72, ' '))}`);
3708
+ }
3709
+ }
3710
+ if (drifts.some((d) => d.delta < 0)) {
3711
+ console.log(` ${c.dim(wrap(t.profile.coverageDriftWhy(), 74, ' '))}`);
3712
+ }
3713
+ }
3599
3714
  }
3600
3715
  }
3601
3716