@trazum/cli 1.36.0 → 1.37.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.36.0",
3
+ "version": "1.37.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.36.0"
40
+ "@trazum/core": "1.37.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -78,6 +78,16 @@ ${bold('OPTIONS FOR rank')}
78
78
  --model, --calls, Price the recoverable tokens, as in optimize.
79
79
  --output-tokens, --batch
80
80
  --prompt <name> Which marked prompt to take from each source file.
81
+ --by-source The fleet: one summary per service from the
82
+ config's "sources" block (name → glob patterns
83
+ over log paths), plus a rollup naming the source
84
+ where the money is. Shares compare totals, and
85
+ when the sources' logs cover different periods
86
+ the report says so rather than letting a 3-day
87
+ log look cheap beside a 30-day one. Budgets per
88
+ service live in spend.bySource and fail the run
89
+ naming the service. Files matching no pattern
90
+ are named, never silently dropped.
81
91
  --markdown-out <file> Also write the ranking as Markdown, for a CI job
82
92
  summary or a pull request comment.
83
93
  --json The ranking as data.
@@ -1227,6 +1237,31 @@ ${bold('EXAMPLES')}
1227
1237
  'A ✗ is not a defect in the log; it is a finding this log cannot support yet. The README\'s recording recipe carries every field above.',
1228
1238
  dryRunNoGates: () =>
1229
1239
  '--dry-run produces no bill, so a gate beside it would exit green having judged nothing. Run the gates without --dry-run.',
1240
+ bySourceNeedsConfig: () =>
1241
+ '--by-source reads the "sources" block in trazum.config.json — a name per service, each with glob patterns over log paths — and this config has none. Name at least one source.',
1242
+ bySourceNothingMatched: (sources) =>
1243
+ 'No log file matched any source pattern. The sources configured: ${sources}. Patterns match the paths as given on the command line.'.replace('${sources}', sources),
1244
+ fleetHeading: (count, total, calls) => `The fleet: ${count} sources · ${total} · ${calls}`,
1245
+ fleetRow: (name, usd, share, calls, span) => `${name} ${usd} ${share} of the fleet · ${calls} · ${span}`,
1246
+ fleetSpan: (days) => `${days} days`,
1247
+ fleetNoClock: () => 'no clock',
1248
+ fleetWorst: (name, usd, share) =>
1249
+ `${name} is where the money is: ${usd}, ${share} of the fleet's total.`,
1250
+ fleetMismatchedSpans: () =>
1251
+ 'These sources cover different periods, so the shares above compare totals, not rates — a 3-day log looks cheap next to a 30-day one for reasons that have nothing to do with cost. Each row states its own span.',
1252
+ fleetSplitBrain: (label, detail) =>
1253
+ `The same workload runs on different models in different sources — ${label}: ${detail}. Same job, different rate cards; whether that is a decision or an accident is not in the logs.`,
1254
+ fleetCacheUnderwater: (name, usd) =>
1255
+ `Caching pays for the fleet overall but loses ${usd} in ${name} — the aggregate verdict was hiding it.`,
1256
+ fleetUnmatched: (file) =>
1257
+ `${file} matched no source pattern, so it is in no report above — spend missing from every bill until a pattern covers it.`,
1258
+ fleetFooter: () =>
1259
+ 'Summaries per source; the full report for one service is "trazum profile <its logs>". Same thresholds, same findings, one source at a time.',
1260
+ fleetBudgetOk: (name, usd, max) => `Within budget: ${name} spent ${usd} against its ${max} in spend.bySource.`,
1261
+ fleetBudgetFailed: (name, usd, max) =>
1262
+ `FAILED — ${name} spent ${usd} against its budget of ${max} in spend.bySource. The fleet total can be fine while one service bleeds; this gate names which.`,
1263
+ fleetBudgetMissing: (name) =>
1264
+ `${name} has a budget in spend.bySource and no logs matched it in this run, so nothing was measured for it. Not a pass: a service that did not appear is not a service under budget.`,
1230
1265
  coverageHeading: () => 'What this log cannot answer yet',
1231
1266
  needsLabel: (seen) =>
1232
1267
  `"label" on ${seen} records: without it every workload is one row, so no per-workload spend, no drill-down, and the levers describe a mixture rather than a decision.`,
package/src/i18n/es.ts CHANGED
@@ -66,6 +66,18 @@ ${bold('OPCIONES DE rank')}
66
66
  --model, --calls, Calcula el coste de los tokens recuperables, como
67
67
  --output-tokens, --batch en optimize.
68
68
  --prompt <nombre> Qué prompt marcado tomar de cada fichero de código.
69
+ --by-source La flota: un resumen por servicio desde el
70
+ bloque "sources" de la config (nombre → patrones
71
+ glob sobre rutas de registros), más un rollup
72
+ que nombra la fuente donde está el dinero. Los
73
+ porcentajes comparan totales, y cuando los
74
+ registros cubren periodos distintos el informe
75
+ lo dice en vez de dejar que un registro de 3
76
+ días parezca barato junto a uno de 30. Los
77
+ presupuestos por servicio viven en
78
+ spend.bySource y hacen fallar la ejecución
79
+ nombrando el servicio. Los ficheros sin patrón
80
+ se nombran, nunca se descartan en silencio.
69
81
  --markdown-out <fichero> Escribe además el ranking en Markdown, para el
70
82
  resumen de un job de CI o un comentario de PR.
71
83
  --json El ranking como datos.
@@ -1243,6 +1255,31 @@ ${bold('EJEMPLOS')}
1243
1255
  'Una ✗ no es un defecto del registro; es un hallazgo que este registro aún no puede sostener. La receta de registro del README lleva todos los campos de arriba.',
1244
1256
  dryRunNoGates: () =>
1245
1257
  '--dry-run no produce factura, así que un gate a su lado saldría en verde sin haber juzgado nada. Corre los gates sin --dry-run.',
1258
+ bySourceNeedsConfig: () =>
1259
+ '--by-source lee el bloque "sources" de trazum.config.json — un nombre por servicio, cada uno con patrones glob sobre rutas de registros — y esta config no tiene ninguno. Nombra al menos una fuente.',
1260
+ bySourceNothingMatched: (sources) =>
1261
+ 'Ningún fichero de registro coincidió con ningún patrón de fuente. Las fuentes configuradas: ${sources}. Los patrones casan con las rutas tal como se dan en la línea de comandos.'.replace('${sources}', sources),
1262
+ fleetHeading: (count, total, calls) => `La flota: ${count} fuentes · ${total} · ${calls}`,
1263
+ fleetRow: (name, usd, share, calls, span) => `${name} ${usd} ${share} de la flota · ${calls} · ${span}`,
1264
+ fleetSpan: (days) => `${days} días`,
1265
+ fleetNoClock: () => 'sin reloj',
1266
+ fleetWorst: (name, usd, share) =>
1267
+ `${name} es donde está el dinero: ${usd}, el ${share} del total de la flota.`,
1268
+ fleetMismatchedSpans: () =>
1269
+ 'Estas fuentes cubren periodos distintos, así que los porcentajes de arriba comparan totales, no ritmos — un registro de 3 días parece barato junto a uno de 30 por motivos que nada tienen que ver con el coste. Cada fila indica su propio periodo.',
1270
+ fleetSplitBrain: (label, detail) =>
1271
+ `La misma carga corre en modelos distintos en fuentes distintas — ${label}: ${detail}. Mismo trabajo, tarifas distintas; si es una decisión o un accidente no está en los registros.`,
1272
+ fleetCacheUnderwater: (name, usd) =>
1273
+ `La caché es rentable para la flota en conjunto pero pierde ${usd} en ${name} — el veredicto agregado lo escondía.`,
1274
+ fleetUnmatched: (file) =>
1275
+ `${file} no coincidió con ningún patrón de fuente, así que no está en ningún informe de arriba — gasto ausente de todas las facturas hasta que un patrón lo cubra.`,
1276
+ fleetFooter: () =>
1277
+ 'Resúmenes por fuente; el informe completo de un servicio es "trazum profile <sus registros>". Mismos umbrales, mismos hallazgos, una fuente cada vez.',
1278
+ fleetBudgetOk: (name, usd, max) => `Dentro de presupuesto: ${name} gastó ${usd} contra su ${max} en spend.bySource.`,
1279
+ fleetBudgetFailed: (name, usd, max) =>
1280
+ `FALLÓ — ${name} gastó ${usd} contra su presupuesto de ${max} en spend.bySource. El total de la flota puede estar bien mientras un servicio sangra; este gate nombra cuál.`,
1281
+ fleetBudgetMissing: (name) =>
1282
+ `${name} tiene presupuesto en spend.bySource y ningún registro coincidió con él en esta ejecución, así que no se midió nada. No es un aprobado: un servicio que no apareció no es un servicio dentro de presupuesto.`,
1246
1283
  coverageHeading: () => 'Lo que este registro todavía no puede responder',
1247
1284
  needsLabel: (seen) =>
1248
1285
  `"label" en ${seen} registros: sin él todas las cargas son una sola fila, así que no hay gasto por carga, ni zoom, y las palancas describen una mezcla en vez de una decisión.`,
package/src/i18n/types.ts CHANGED
@@ -975,6 +975,26 @@ export interface CliMessages {
975
975
  dryRunNoCacheTraffic(): string;
976
976
  dryRunFooter(): string;
977
977
  dryRunNoGates(): string;
978
+ /**
979
+ * `--by-source`: the fleet. One summary per service, the rollup naming
980
+ * the worst offender, cross-source findings a merged bill cannot make,
981
+ * and per-source budgets that fail naming the service.
982
+ */
983
+ bySourceNeedsConfig(): string;
984
+ bySourceNothingMatched(sources: string): string;
985
+ fleetHeading(count: string, total: string, calls: string): string;
986
+ fleetRow(name: string, usd: string, share: string, calls: string, span: string): string;
987
+ fleetSpan(days: string): string;
988
+ fleetNoClock(): string;
989
+ fleetWorst(name: string, usd: string, share: string): string;
990
+ fleetMismatchedSpans(): string;
991
+ fleetSplitBrain(label: string, detail: string): string;
992
+ fleetCacheUnderwater(name: string, usd: string): string;
993
+ fleetUnmatched(file: string): string;
994
+ fleetFooter(): string;
995
+ fleetBudgetOk(name: string, usd: string, max: string): string;
996
+ fleetBudgetFailed(name: string, usd: string, max: string): string;
997
+ fleetBudgetMissing(name: string): string;
978
998
  coverageHeading(): string;
979
999
  needsLabel(seen: string): string;
980
1000
  needsSession(seen: string): string;
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  coverageDrift,
24
24
  driversBetween,
25
25
  explainGateFailure,
26
+ assignSources,
27
+ fleetRollup,
26
28
  labelCoverage,
27
29
  measuredUsage,
28
30
  gateMargin,
@@ -74,6 +76,7 @@ import {
74
76
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
75
77
  import { dayOf, formatGap, median, spanDays } from './time.js';
76
78
  import type {
79
+ FleetSource,
77
80
  MeasuredUsage,
78
81
  BaselineBreach,
79
82
  BaselineChange,
@@ -461,7 +464,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
461
464
  ],
462
465
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
463
466
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
464
- 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', 'dry-run', 'markdown-summary'],
467
+ 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', 'dry-run', 'markdown-summary', 'by-source'],
465
468
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
466
469
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
467
470
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2241,10 +2244,17 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2241
2244
  const target = await stat(path).catch(() => null);
2242
2245
  let logFiles: string[] = [path];
2243
2246
  if (target?.isDirectory()) {
2244
- const entries = await readdir(path, { withFileTypes: true });
2247
+ /**
2248
+ * Recursive under `--by-source`, flat otherwise. The fleet's whole point
2249
+ * is one directory per service, so the walk must descend; the flat mode
2250
+ * keeps its long-standing behaviour because a directory of rotated logs
2251
+ * with an unrelated subfolder should not quietly absorb it.
2252
+ */
2253
+ const bySourceMode = boolFlag(args, 'by-source');
2254
+ const entries = await readdir(path, { withFileTypes: true, recursive: bySourceMode });
2245
2255
  logFiles = entries
2246
2256
  .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2247
- .map((entry) => join(path, entry.name))
2257
+ .map((entry) => join(entry.parentPath ?? path, entry.name))
2248
2258
  .sort((a, b) => a.localeCompare(b));
2249
2259
  if (logFiles.length === 0) {
2250
2260
  throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
@@ -2266,6 +2276,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2266
2276
  // A file that does not end in a newline would otherwise glue its last record
2267
2277
  // to the next file's first one, and both would be reported as unreadable.
2268
2278
  const raw = logTexts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2279
+
2269
2280
  /**
2270
2281
  * The drill-down. A label that matches nothing is an error naming the labels
2271
2282
  * that exist — the route command's rule, for the route command's reason: a
@@ -2368,6 +2379,123 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2368
2379
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2369
2380
  const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
2370
2381
 
2382
+ /**
2383
+ * `--by-source`: one report per service, plus the rollup — the fleet.
2384
+ *
2385
+ * A merged bill is right for one service and wrong for twelve: it hides
2386
+ * which service the money comes from, per-service budgets cannot exist,
2387
+ * and the findings a comparison between services could make are invisible.
2388
+ * Files are assigned to sources by the most specific matching glob from the
2389
+ * config's `sources` block; a file matching no source is named loudly,
2390
+ * because a log that silently joined no report is spend missing from every
2391
+ * bill.
2392
+ */
2393
+ if (boolFlag(args, 'by-source')) {
2394
+ const sourceDefs = config.sources;
2395
+ if (sourceDefs === undefined || Object.keys(sourceDefs).length === 0) {
2396
+ throw new Error(t.profile.bySourceNeedsConfig());
2397
+ }
2398
+ const { bySource, unmatched } = assignSources(logFiles, sourceDefs);
2399
+ if (bySource.size === 0) {
2400
+ throw new Error(t.profile.bySourceNothingMatched(Object.keys(sourceDefs).join(', ')));
2401
+ }
2402
+
2403
+ const textByFile = new Map(logFiles.map((file, i) => [file, logTexts[i]!]));
2404
+ const fleetSources: FleetSource[] = [];
2405
+ const cacheDeltas = new Map<string, number>();
2406
+ for (const [name, files] of [...bySource.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2407
+ const text = files
2408
+ .map((file) => textByFile.get(file)!)
2409
+ .map((chunk) => (chunk.endsWith('\n') ? chunk : `${chunk}\n`))
2410
+ .join('');
2411
+ const sourceReport = profileUsage(text, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
2412
+ fleetSources.push({ name, report: sourceReport });
2413
+ cacheDeltas.set(name, cacheEconomics(sourceReport.total).deltaUsd);
2414
+ }
2415
+ const aggregate = profileUsage(raw, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
2416
+ const rollup = fleetRollup(fleetSources, {
2417
+ cacheDeltas,
2418
+ aggregateCacheDelta: cacheEconomics(aggregate.total).deltaUsd,
2419
+ });
2420
+
2421
+ if (boolFlag(args, 'json')) {
2422
+ console.log(
2423
+ JSON.stringify(
2424
+ {
2425
+ schemaVersion: 1,
2426
+ bySource: fleetSources.map((source) => ({ name: source.name, report: source.report })),
2427
+ rollup: {
2428
+ totalUsd: rollup.totalUsd,
2429
+ calls: rollup.calls,
2430
+ sources: rollup.sources,
2431
+ worst: rollup.worst,
2432
+ mismatchedSpans: rollup.mismatchedSpans,
2433
+ splitBrains: rollup.splitBrains,
2434
+ cacheUnderwater: rollup.cacheUnderwater,
2435
+ unmatchedFiles: unmatched,
2436
+ },
2437
+ },
2438
+ (key, value) => (value instanceof Map ? undefined : value),
2439
+ 2,
2440
+ ),
2441
+ );
2442
+ } else {
2443
+ console.log(c.bold(t.profile.fleetHeading(n(rollup.sources.length), formatUsd(rollup.totalUsd), t.profile.calls(rollup.calls))));
2444
+ for (const row of rollup.sources) {
2445
+ const span = row.spanDays === null ? t.profile.fleetNoClock() : t.profile.fleetSpan(row.spanDays.toFixed(1));
2446
+ console.log(
2447
+ ` ${t.profile.fleetRow(row.name, formatUsd(row.usd), pct(row.share), t.profile.calls(row.calls), span)}`,
2448
+ );
2449
+ }
2450
+ if (rollup.worst !== null && rollup.sources.length > 1) {
2451
+ console.log();
2452
+ console.log(` ${c.yellow('!')} ${c.bold(wrap(t.profile.fleetWorst(rollup.worst.name, formatUsd(rollup.worst.usd), pct(rollup.worst.share)), 74, ' '))}`);
2453
+ }
2454
+ if (rollup.mismatchedSpans) {
2455
+ console.log(` ${c.dim(wrap(t.profile.fleetMismatchedSpans(), 74, ' '))}`);
2456
+ }
2457
+ for (const split of rollup.splitBrains.slice(0, 3)) {
2458
+ console.log();
2459
+ console.log(
2460
+ ` ${c.yellow('!')} ${wrap(t.profile.fleetSplitBrain(split.label, split.sources.map((v) => `${v.name} → ${v.model} (${formatUsd(v.usd)})`).join(', ')), 74, ' ')}`,
2461
+ );
2462
+ }
2463
+ for (const under of rollup.cacheUnderwater.slice(0, 3)) {
2464
+ console.log(
2465
+ ` ${c.yellow('!')} ${wrap(t.profile.fleetCacheUnderwater(under.name, formatUsd(under.deltaUsd)), 74, ' ')}`,
2466
+ );
2467
+ }
2468
+ for (const file of unmatched) {
2469
+ console.log(` ${c.yellow('!')} ${wrap(t.profile.fleetUnmatched(file), 74, ' ')}`);
2470
+ }
2471
+ console.log();
2472
+ console.log(` ${c.dim(wrap(t.profile.fleetFooter(), 74, ' '))}`);
2473
+ }
2474
+
2475
+ /**
2476
+ * The per-source gates. Each budget judges its own service and the run
2477
+ * fails naming the service — a total that hides which source crossed its
2478
+ * line is the rendering this mode exists to end. Waivable per source
2479
+ * through `bySource:<name>`, under the same expiry discipline.
2480
+ */
2481
+ const bySourceBudgets = config.spend?.bySource ?? {};
2482
+ for (const [name, limit] of Object.entries(bySourceBudgets)) {
2483
+ const found = fleetSources.find((source) => source.name === name);
2484
+ if (found === undefined) {
2485
+ console.error(c.dim(t.profile.fleetBudgetMissing(name)));
2486
+ continue;
2487
+ }
2488
+ const usd = found.report.total.totalUsd;
2489
+ if (usd > limit) {
2490
+ console.error(c.red(t.profile.fleetBudgetFailed(name, formatUsd(usd), formatUsd(limit))));
2491
+ process.exitCode = 1;
2492
+ } else {
2493
+ console.error(c.dim(t.profile.fleetBudgetOk(name, formatUsd(usd), formatUsd(limit))));
2494
+ }
2495
+ }
2496
+ return;
2497
+ }
2498
+
2371
2499
  /**
2372
2500
  * `--dry-run`: what this log could and could not answer, and no bill.
2373
2501
  *