@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/README.md +1 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +25 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +27 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +20 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +114 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +35 -0
- package/src/i18n/es.ts +37 -0
- package/src/i18n/types.ts +20 -0
- package/src/index.ts +131 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
-
|
|
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
|
*
|