@trazum/cli 1.50.5 → 1.50.6

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.50.5",
3
+ "version": "1.50.6",
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.50.5"
40
+ "@trazum/core": "1.50.6"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -58,6 +58,7 @@ ${bold('USAGE')}
58
58
  trazum models
59
59
  trazum rules
60
60
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
61
+ trazum ladder <log>
61
62
  trazum feedback
62
63
  trazum --version
63
64
 
@@ -597,6 +598,12 @@ ${bold('CONFIG FILE')}
597
598
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — money budgets for
598
599
  "trazum profile", in dollars. A budgeted label with no calls in
599
600
  the log is reported as not measured, never as a pass
601
+ ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
602
+ "escalateOn": ["escalated"] } } — cheap model first, escalate a
603
+ recorded failure to a dearer one. Both fields required.
604
+ "trazum ladder <log>" prints the break-even escalation rate
605
+ beside the measured one: an escalation pays twice, so above that
606
+ rate the ladder costs more than never having built it
600
607
  outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
601
608
  your own vocabulary for what happened, and which of it counts as
602
609
  a win. Both required: which words mean success is a judgement
@@ -957,6 +964,47 @@ ${bold('EXAMPLES')}
957
964
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
958
965
  },
959
966
 
967
+ ladder: {
968
+ heading: () => 'Escalation ladders',
969
+ noLadders: () =>
970
+ 'No ladders configured. A ladder sends a workload to a cheap model first and escalates a recorded failure to a dearer one — add "ladders" to trazum.config.json, for example {"support": {"tiers": ["claude-haiku-4-5", "claude-opus-5"], "escalateOn": ["escalated"]}}.',
971
+ workload: (label) => label,
972
+ arithmetic: (cheap, dear, breakEven) =>
973
+ `${cheap} a call cheap, ${dear} dear. Break-even escalation rate: ${breakEven}.`,
974
+ measured: (rate, escalations, calls) =>
975
+ `Measured: ${rate} (${escalations} of ${calls} calls escalated).`,
976
+ saving: (delta) => `Saving ${delta} a call against never having built it.`,
977
+ costing: (delta) =>
978
+ `Costing ${delta} a call MORE than never having built it. Escalating above the break-even rate means paying for the cheap attempt and the dear one on most calls.`,
979
+ atBreakEven: (band) =>
980
+ `Within ${band} of break-even, so no sign is claimed. Inside that band the answer flips on ordinary week-to-week variation, and "saving" on Monday and "costing" on Thursday from the same policy teaches a reader to ignore the figure.`,
981
+ cannotTell: (why, calls) =>
982
+ why === 'too-few-calls'
983
+ ? `Cannot tell yet: ${calls} calls carried a declared outcome, and a rate over that few moves more from one more call than from anything you could do about it.`
984
+ : why === 'no-outcomes-recorded'
985
+ ? 'Cannot tell: nothing in this log recorded an outcome for this workload, so there is no escalation rate to compare against.'
986
+ : why === 'tier-unpriced'
987
+ ? 'Cannot tell: one of the tiers is not in the price catalogue, so the break-even rate cannot be computed.'
988
+ : 'Cannot tell: this ladder declares no escalation values.',
989
+ problemsHeading: (label) => `${label} — this ladder will not do what it looks like it does`,
990
+ problem: (kind, detail) =>
991
+ kind === 'escalate-on-a-success'
992
+ ? `escalateOn names "${detail}", which "outcomes.success" declares a SUCCESS. This ladder pays twice for work that already worked, on every call, while looking exactly like a cost-saving measure.`
993
+ : kind === 'escalate-on-undeclared'
994
+ ? `escalateOn names "${detail}", which "outcomes.values" does not declare. This ladder never fires, silently.`
995
+ : kind === 'tiers-not-cheapest-first'
996
+ ? `"${detail}" is cheaper than the tier before it. That is not a ladder; it is a routing rule that escalates to something cheaper and reports a saving for it.`
997
+ : kind === 'duplicate-tier'
998
+ ? `"${detail}" appears twice in tiers.`
999
+ : kind === 'unknown-model'
1000
+ ? `"${detail}" is not in the price catalogue.`
1001
+ : `a ladder needs at least two tiers; this one has ${detail}.`,
1002
+ theDoubleSpend: () =>
1003
+ 'An escalation pays twice: the cheap attempt is not refunded. So a ladder saves money only below its break-even escalation rate, and above it costs more than never having built one — which is why the rate is printed beside the measurement rather than left to be worked out in somebody\u2019s head.',
1004
+ notExecuted: () =>
1005
+ 'Trazum does not run the escalation. A ladder escalates after a failure is known, which is after the answer came back and usually after something downstream judged it, so the retry belongs in your own loop. What is here is the policy and the arithmetic that says whether the policy is worth running.',
1006
+ },
1007
+
960
1008
  gateway: {
961
1009
  badProvider: (given, known) =>
962
1010
  given === ''
package/src/i18n/es.ts CHANGED
@@ -45,6 +45,7 @@ ${bold('USO')}
45
45
  trazum models
46
46
  trazum rules
47
47
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
48
+ trazum ladder <log>
48
49
  trazum feedback
49
50
  trazum --version
50
51
 
@@ -617,6 +618,12 @@ ${bold('FICHERO DE CONFIGURACIÓN')}
617
618
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — presupuestos en
618
619
  dólares para "trazum profile". Una etiqueta con presupuesto y sin
619
620
  llamadas se informa como no medida, nunca como aprobada
621
+ ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
622
+ "escalateOn": ["escalated"] } } — modelo barato primero, escalar
623
+ un fallo registrado a uno más caro. Los dos campos obligatorios.
624
+ "trazum ladder <log>" imprime la tasa de escalado de equilibrio
625
+ junto a la medida: un escalado paga dos veces, así que por encima
626
+ de esa tasa la escalera cuesta más que no haberla construido
620
627
  outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
621
628
  tu propio vocabulario para lo que pasó, y cuál de él cuenta como
622
629
  acierto. Los dos son obligatorios: qué palabras significan éxito
@@ -993,6 +1000,47 @@ ${bold('EJEMPLOS')}
993
1000
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
994
1001
  },
995
1002
 
1003
+ ladder: {
1004
+ heading: () => 'Escaleras de escalado',
1005
+ noLadders: () =>
1006
+ 'No hay escaleras configuradas. Una escalera manda una carga a un modelo barato primero y escala un fallo registrado a uno m\u00e1s caro — a\u00f1ade "ladders" a trazum.config.json, por ejemplo {"support": {"tiers": ["claude-haiku-4-5", "claude-opus-5"], "escalateOn": ["escalated"]}}.',
1007
+ workload: (label) => label,
1008
+ arithmetic: (cheap, dear, breakEven) =>
1009
+ `${cheap} por llamada barata, ${dear} cara. Tasa de escalado de equilibrio: ${breakEven}.`,
1010
+ measured: (rate, escalations, calls) =>
1011
+ `Medido: ${rate} (${escalations} de ${calls} llamadas escalaron).`,
1012
+ saving: (delta) => `Ahorra ${delta} por llamada frente a no haberla construido.`,
1013
+ costing: (delta) =>
1014
+ `Cuesta ${delta} por llamada M\u00c1S que no haberla construido. Escalar por encima de la tasa de equilibrio significa pagar el intento barato y el caro en la mayor\u00eda de las llamadas.`,
1015
+ atBreakEven: (band) =>
1016
+ `A menos de ${band} del equilibrio, as\u00ed que no se afirma ning\u00fan signo. Dentro de esa banda la respuesta cambia con la variaci\u00f3n normal de una semana a otra, y decir "ahorra" el lunes y "cuesta" el jueves con la misma pol\u00edtica ense\u00f1a a ignorar la cifra.`,
1017
+ cannotTell: (why, calls) =>
1018
+ why === 'too-few-calls'
1019
+ ? `A\u00fan no se puede decir: ${calls} llamadas llevaron un resultado declarado, y una tasa sobre tan pocas se mueve m\u00e1s con una llamada m\u00e1s que con nada que pudieras hacer.`
1020
+ : why === 'no-outcomes-recorded'
1021
+ ? 'No se puede decir: nada en este log registr\u00f3 un resultado para esta carga, as\u00ed que no hay tasa de escalado con la que comparar.'
1022
+ : why === 'tier-unpriced'
1023
+ ? 'No se puede decir: uno de los pelda\u00f1os no est\u00e1 en la tabla de precios, as\u00ed que la tasa de equilibrio no se puede calcular.'
1024
+ : 'No se puede decir: esta escalera no declara valores de escalado.',
1025
+ problemsHeading: (label) => `${label} — esta escalera no va a hacer lo que parece`,
1026
+ problem: (kind, detail) =>
1027
+ kind === 'escalate-on-a-success'
1028
+ ? `escalateOn nombra "${detail}", que "outcomes.success" declara como \u00c9XITO. Esta escalera paga dos veces por trabajo que ya funcion\u00f3, en cada llamada, con el aspecto exacto de una medida de ahorro.`
1029
+ : kind === 'escalate-on-undeclared'
1030
+ ? `escalateOn nombra "${detail}", que "outcomes.values" no declara. Esta escalera no se dispara nunca, en silencio.`
1031
+ : kind === 'tiers-not-cheapest-first'
1032
+ ? `"${detail}" es m\u00e1s barato que el pelda\u00f1o anterior. Eso no es una escalera; es una regla de enrutado que escala a algo m\u00e1s barato y lo reporta como ahorro.`
1033
+ : kind === 'duplicate-tier'
1034
+ ? `"${detail}" aparece dos veces en tiers.`
1035
+ : kind === 'unknown-model'
1036
+ ? `"${detail}" no est\u00e1 en la tabla de precios.`
1037
+ : `una escalera necesita al menos dos pelda\u00f1os; esta tiene ${detail}.`,
1038
+ theDoubleSpend: () =>
1039
+ 'Un escalado paga dos veces: el intento barato no se devuelve. As\u00ed que una escalera solo ahorra por debajo de su tasa de escalado de equilibrio, y por encima cuesta m\u00e1s que no haberla construido — por eso la tasa se imprime junto a la medici\u00f3n en vez de dejarla para calcularla de cabeza.',
1040
+ notExecuted: () =>
1041
+ 'Trazum no ejecuta el escalado. Una escalera escala despu\u00e9s de conocer un fallo, que es despu\u00e9s de que llegue la respuesta y normalmente despu\u00e9s de que algo la juzgue, as\u00ed que el reintento va en tu propio bucle. Lo que hay aqu\u00ed es la pol\u00edtica y la aritm\u00e9tica que dice si merece la pena.',
1042
+ },
1043
+
996
1044
  gateway: {
997
1045
  badProvider: (given, known) =>
998
1046
  given === ''
package/src/i18n/types.ts CHANGED
@@ -264,6 +264,26 @@ export interface CliMessages {
264
264
  * between somebody and their provider is trusted on nothing but what it says
265
265
  * plainly at start-up.
266
266
  */
267
+ /**
268
+ * The ladder. Every line here exists to stop somebody reading "we route to
269
+ * the cheap model first" as a saving without the number that decides it.
270
+ */
271
+ ladder: {
272
+ heading(): string;
273
+ noLadders(): string;
274
+ workload(label: string): string;
275
+ arithmetic(cheap: string, dear: string, breakEven: string): string;
276
+ measured(rate: string, escalations: string, calls: string): string;
277
+ saving(delta: string): string;
278
+ costing(delta: string): string;
279
+ atBreakEven(band: string): string;
280
+ cannotTell(why: string, calls: string): string;
281
+ problem(kind: string, detail: string): string;
282
+ problemsHeading(label: string): string;
283
+ theDoubleSpend(): string;
284
+ notExecuted(): string;
285
+ };
286
+
267
287
  gateway: {
268
288
  badProvider(given: string, known: string): string;
269
289
  needsPolicy(policies: string): string;
package/src/index.ts CHANGED
@@ -39,6 +39,9 @@ import {
39
39
  DEFAULT_USAGE,
40
40
  budgetPositions,
41
41
  conform,
42
+ BREAK_EVEN_BAND,
43
+ ladderPosition,
44
+ validateLadder,
42
45
  outcomeReport,
43
46
  rankPerOutcome,
44
47
  FAILURE_POLICIES,
@@ -570,6 +573,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
570
573
  conform: ['contract', 'json'],
571
574
  feedback: [],
572
575
  gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
576
+ ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
573
577
  where: [],
574
578
  rules: [],
575
579
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2190,6 +2194,136 @@ async function commandGateway(
2190
2194
  console.log();
2191
2195
  }
2192
2196
 
2197
+ /**
2198
+ * `trazum ladder <log>` — is the ladder saving money, or is it a bill?
2199
+ *
2200
+ * The one number this command exists to print is the **break-even escalation
2201
+ * rate**. "We route to the cheap model first" describes a policy that saves
2202
+ * money and a policy that costs money equally well; only the rate separates
2203
+ * them, and nobody works it out in their head because the shape of the
2204
+ * arithmetic is not obvious — an escalation pays twice, since the cheap
2205
+ * attempt is not refunded.
2206
+ */
2207
+ async function commandLadder(
2208
+ args: Args,
2209
+ config: TrazumConfig,
2210
+ pricing: PricingCatalogue,
2211
+ t: CliMessages,
2212
+ ): Promise<void> {
2213
+ const path = args.positional[0];
2214
+ if (path === undefined) {
2215
+ throw new Error(t.errors.missingInputFile());
2216
+ }
2217
+ const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
2218
+ const ladders = config.ladders ?? {};
2219
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2220
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2221
+
2222
+ console.log();
2223
+ console.log(c.bold(t.ladder.heading()));
2224
+ if (Object.keys(ladders).length === 0) {
2225
+ console.log(` ${c.dim(wrap(t.ladder.noLadders(), 74, ' '))}`);
2226
+ console.log();
2227
+ return;
2228
+ }
2229
+ console.log(` ${c.dim(wrap(t.ladder.theDoubleSpend(), 74, ' '))}`);
2230
+ console.log();
2231
+
2232
+ const vocabulary = config.outcomes ?? null;
2233
+ let anyProblem = false;
2234
+
2235
+ for (const [label, policy] of Object.entries(ladders)) {
2236
+ /**
2237
+ * Validated before it is measured, and loudly.
2238
+ *
2239
+ * A ladder that escalates on a value declared a *success* pays twice for
2240
+ * work that already worked, on every call, while looking exactly like a
2241
+ * cost-saving measure in the config. Printing its measured position first
2242
+ * would bury that under a number.
2243
+ */
2244
+ const problems = validateLadder(policy, vocabulary, pricing);
2245
+ if (problems.length > 0) {
2246
+ anyProblem = true;
2247
+ console.log(` ${c.red('✗')} ${c.bold(t.ladder.problemsHeading(label))}`);
2248
+ for (const problem of problems) {
2249
+ const detail =
2250
+ 'value' in problem
2251
+ ? problem.value
2252
+ : 'model' in problem
2253
+ ? problem.model
2254
+ : String(problem.tiers);
2255
+ console.log(` ${wrap(t.ladder.problem(problem.kind, detail), 70, ' ')}`);
2256
+ }
2257
+ console.log();
2258
+ continue;
2259
+ }
2260
+
2261
+ const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
2262
+ const breakdown = report.byLabel.find((entry) => entry.label === label);
2263
+ /**
2264
+ * The shape of the work comes from the measured calls, so the break-even
2265
+ * rate is priced against what this workload actually sends rather than
2266
+ * against a token count somebody guessed at.
2267
+ */
2268
+ const calls = breakdown?.breakdown.calls ?? 0;
2269
+ const shape =
2270
+ breakdown === undefined || calls === 0
2271
+ ? { inputTokens: 0, outputTokens: 0 }
2272
+ : {
2273
+ inputTokens: Math.round(
2274
+ (breakdown.breakdown.inputTokens +
2275
+ breakdown.breakdown.cacheReadTokens +
2276
+ breakdown.breakdown.cacheWriteTokens) /
2277
+ calls,
2278
+ ),
2279
+ outputTokens: Math.round(breakdown.breakdown.outputTokens / calls),
2280
+ };
2281
+
2282
+ const empty = { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 };
2283
+ const position = ladderPosition(policy, slice?.tally ?? empty, shape, vocabulary, pricing);
2284
+
2285
+ console.log(` ${c.bold(t.ladder.workload(label))} ${c.dim(policy.tiers.join(' → '))}`);
2286
+ console.log(
2287
+ ` ${c.dim(
2288
+ t.ladder.arithmetic(
2289
+ formatUsd(position.arithmetic.cheapUsd),
2290
+ formatUsd(position.arithmetic.dearUsd),
2291
+ position.arithmetic.breakEvenRate === null ? '—' : pct(position.arithmetic.breakEvenRate),
2292
+ ),
2293
+ )}`,
2294
+ );
2295
+
2296
+ if (position.verdict === 'cannot-tell') {
2297
+ console.log(
2298
+ ` ${c.yellow('?')} ${wrap(t.ladder.cannotTell(position.unknown ?? '', n(position.calls)), 70, ' ')}`,
2299
+ );
2300
+ } else {
2301
+ console.log(
2302
+ ` ${t.ladder.measured(pct(position.measuredRate ?? 0), n(position.escalations), n(position.calls))}`,
2303
+ );
2304
+ const delta = formatUsd(Math.abs(position.deltaUsdPerCall ?? 0));
2305
+ if (position.verdict === 'saving') {
2306
+ console.log(` ${c.green('✓')} ${wrap(t.ladder.saving(delta), 70, ' ')}`);
2307
+ } else if (position.verdict === 'costing') {
2308
+ console.log(` ${c.red('✗')} ${wrap(t.ladder.costing(delta), 70, ' ')}`);
2309
+ } else {
2310
+ console.log(` ${c.dim('·')} ${wrap(t.ladder.atBreakEven(pct(BREAK_EVEN_BAND)), 70, ' ')}`);
2311
+ }
2312
+ }
2313
+ console.log();
2314
+ }
2315
+
2316
+ console.log(` ${c.dim(wrap(t.ladder.notExecuted(), 74, ' '))}`);
2317
+ console.log();
2318
+
2319
+ /**
2320
+ * A misconfigured ladder fails the command, because it is the one finding
2321
+ * here that is wrong *now* rather than a measurement somebody should look
2322
+ * at. Everything else exits 0: this is a survey, like `doctor`.
2323
+ */
2324
+ if (anyProblem) process.exitCode = 1;
2325
+ }
2326
+
2193
2327
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2194
2328
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2195
2329
  const col = t.models.columns;
@@ -8390,6 +8524,9 @@ async function main(): Promise<void> {
8390
8524
  case 'models':
8391
8525
  commandModels(t, pricing);
8392
8526
  break;
8527
+ case 'ladder':
8528
+ await commandLadder(args, config, pricing, t);
8529
+ break;
8393
8530
  case 'gateway':
8394
8531
  await commandGateway(args, config, configDir, pricing, t);
8395
8532
  break;