@trazum/cli 1.50.2 → 1.50.4

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.2",
3
+ "version": "1.50.4",
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.2"
40
+ "@trazum/core": "1.50.4"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
@@ -0,0 +1,350 @@
1
+ /**
2
+ * The proxy that stands in the path, and does as little as possible there.
3
+ *
4
+ * The decision lives in `@trazum/core`'s `gatewayDecision`, which never sees a
5
+ * prompt and cannot return a modified request. This file moves bytes: read a
6
+ * body, ask, and either forward it **unchanged** or answer with the refusal.
7
+ * The split is the safety property — everything that could go wrong in a
8
+ * judgement is tested without a socket, and everything that could go wrong on
9
+ * a socket has no judgement in it.
10
+ *
11
+ * **Loopback only, and the address is not a flag.** Same posture as `serve`
12
+ * since 1.44, and more load-bearing here: this thing has somebody's provider
13
+ * credential passing through it. `127.0.0.1` is compiled in.
14
+ *
15
+ * **The credential is not even borrowed.** The caller's own `authorization`
16
+ * and `x-api-key` headers are forwarded untouched and never read, never
17
+ * stored, never logged, and never put in a URL. Trazum holds no key for the
18
+ * gateway and has no way to make a call of its own through it — which is a
19
+ * stronger promise than the connector's *borrowed, never held*, and the right
20
+ * one for a component sitting between somebody and their provider.
21
+ *
22
+ * **The upstream is compiled in.** A flag naming the host would turn this into
23
+ * a credential-forwarding open proxy: anything that could rewrite a config on
24
+ * disk could point a company's API key at a machine it chose. `checkedEndpoint`
25
+ * has guarded Trazum's outbound calls on that principle since 1.14, and here
26
+ * there is no caller-supplied endpoint at all.
27
+ *
28
+ * **Nothing about the payload is written down.** The body is read to count
29
+ * tokens and to find the model, then forwarded and dropped. It is never
30
+ * logged, never stored, and never included in a refusal — the store has held
31
+ * aggregates since 1.42 and standing in the path changes nothing about that.
32
+ */
33
+
34
+ import { createServer } from 'node:http';
35
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
36
+ import { estimateTokens, gatewayDecision, usageFromResponse } from '@trazum/core';
37
+ import type { GatewayDecision, GatewayPolicy, GatewayStanding, PricingCatalogue } from '@trazum/core';
38
+
39
+ /** Compiled in. See the module note. */
40
+ export const BIND_HOST = '127.0.0.1';
41
+
42
+ export const DEFAULT_GATEWAY_PORT = 7318;
43
+
44
+ /**
45
+ * Bodies larger than this are refused unread.
46
+ *
47
+ * Larger than `serve`'s limit because a real request carries a real prompt,
48
+ * and smaller than unbounded because a proxy that buffers whatever it is
49
+ * handed is a memory exhaustion away from taking down the application it was
50
+ * installed to protect.
51
+ */
52
+ export const MAX_GATEWAY_BODY_BYTES = 8 * 1024 * 1024;
53
+
54
+ /**
55
+ * Where each provider actually is, and the one path this speaks for it.
56
+ *
57
+ * Deliberately narrow. A gateway that forwarded any path would be a general
58
+ * proxy for somebody's API key, and the budget decision only has meaning for
59
+ * the endpoint that spends tokens.
60
+ */
61
+ export const UPSTREAMS: Readonly<Record<string, { origin: string; path: string }>> = {
62
+ anthropic: { origin: 'https://api.anthropic.com', path: '/v1/messages' },
63
+ openai: { origin: 'https://api.openai.com', path: '/v1/chat/completions' },
64
+ };
65
+
66
+ /**
67
+ * Headers Trazum adds or removes. Everything else the caller sent is forwarded
68
+ * verbatim, including their credential, which this never reads.
69
+ */
70
+ const HOP_BY_HOP = new Set([
71
+ 'connection',
72
+ 'keep-alive',
73
+ 'proxy-authenticate',
74
+ 'proxy-authorization',
75
+ 'te',
76
+ 'trailer',
77
+ 'transfer-encoding',
78
+ 'upgrade',
79
+ 'host',
80
+ 'content-length',
81
+ ]);
82
+
83
+ export interface GatewayContext {
84
+ provider: string;
85
+ catalogue: PricingCatalogue;
86
+ policy: GatewayPolicy;
87
+ /** Where the budget stands, refreshed by the caller — never read per request. */
88
+ standing: () => GatewayStanding | null;
89
+ /**
90
+ * Called after a forwarded call returns, with the provider's own counts.
91
+ *
92
+ * Counts only. There is no parameter here that could carry a prompt, which
93
+ * is what makes "nothing about the payload is written down" a fact about the
94
+ * interface rather than a discipline.
95
+ */
96
+ record: (measured: {
97
+ model: string;
98
+ label: string | null;
99
+ inputTokens: number;
100
+ outputTokens: number;
101
+ cacheReadTokens: number;
102
+ cacheWriteTokens: number;
103
+ substituted: boolean;
104
+ }) => void;
105
+ /** A line for the operator's terminal. Never given a body, ever. */
106
+ note: (line: string) => void;
107
+ /** Injected so the proxy is testable against a stub upstream. */
108
+ fetchImpl?: typeof fetch;
109
+ }
110
+
111
+ async function readBody(request: IncomingMessage): Promise<string | null> {
112
+ const chunks: Buffer[] = [];
113
+ let size = 0;
114
+ for await (const chunk of request) {
115
+ const buffer = Buffer.from(chunk as Buffer);
116
+ size += buffer.length;
117
+ if (size > MAX_GATEWAY_BODY_BYTES) return null;
118
+ chunks.push(buffer);
119
+ }
120
+ return Buffer.concat(chunks).toString('utf8');
121
+ }
122
+
123
+ /**
124
+ * What the request is asking for, without keeping any of it.
125
+ *
126
+ * The token count is the heuristic estimator's — the same one every other
127
+ * estimate in this product uses, with the same documented error band. Counting
128
+ * exactly would mean an API call to count before the API call, which is a
129
+ * round trip in a hot path to make a budget decision marginally sharper.
130
+ *
131
+ * The returned object holds no text. That is the point: everything downstream
132
+ * of here, including the decision and the record, is structurally incapable of
133
+ * carrying a prompt.
134
+ */
135
+ function describe(body: string, provider: string): {
136
+ model: string;
137
+ inputTokens: number | null;
138
+ maxOutputTokens: number | null;
139
+ label: string | null;
140
+ } | null {
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(body);
144
+ } catch {
145
+ return null;
146
+ }
147
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
148
+ const request = parsed as Record<string, unknown>;
149
+ if (typeof request.model !== 'string') return null;
150
+
151
+ // Every text field the wire format puts in front of the model, counted and
152
+ // then dropped. `JSON.stringify` of the messages over-counts by the
153
+ // structural characters, which is the safe direction for a budget: an
154
+ // estimate that runs high refuses slightly early rather than allowing
155
+ // slightly late.
156
+ const parts: string[] = [];
157
+ if (typeof request.system === 'string') parts.push(request.system);
158
+ if (Array.isArray(request.messages)) parts.push(JSON.stringify(request.messages));
159
+ if (Array.isArray(request.input)) parts.push(JSON.stringify(request.input));
160
+
161
+ const text = parts.join('\n');
162
+ const max = provider === 'anthropic' ? request.max_tokens : request.max_completion_tokens ?? request.max_tokens;
163
+
164
+ return {
165
+ model: request.model,
166
+ inputTokens: text === '' ? null : estimateTokens(text),
167
+ maxOutputTokens: typeof max === 'number' && Number.isFinite(max) ? max : null,
168
+ /**
169
+ * `metadata.trazum_label`, and nothing inferred.
170
+ *
171
+ * A label is what makes a per-workload bill possible, and guessing one
172
+ * from a path or a user agent would attribute somebody's spend to a
173
+ * workload they never named.
174
+ */
175
+ label: labelOf(request),
176
+ };
177
+ }
178
+
179
+ function labelOf(request: Record<string, unknown>): string | null {
180
+ const metadata = request.metadata;
181
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return null;
182
+ const label = (metadata as Record<string, unknown>).trazum_label;
183
+ return typeof label === 'string' && label.trim() !== '' ? label : null;
184
+ }
185
+
186
+ /** The refusal, as the caller's SDK will receive it. */
187
+ function refusalBody(decision: Extract<GatewayDecision, { kind: 'refuse' }>): string {
188
+ return `${JSON.stringify(
189
+ {
190
+ schemaVersion: 1,
191
+ error: { type: 'trazum_budget_refusal', message: decision.because },
192
+ reason: decision.reason,
193
+ cause: decision.cause,
194
+ restsOn: decision.restsOn,
195
+ standing: decision.standing,
196
+ estimatedUsd: decision.estimatedUsd,
197
+ alternatives: decision.alternatives,
198
+ },
199
+ null,
200
+ 2,
201
+ )}\n`;
202
+ }
203
+
204
+ export function buildGateway(context: GatewayContext): Server {
205
+ const upstream = UPSTREAMS[context.provider];
206
+ const doFetch = context.fetchImpl ?? fetch;
207
+
208
+ return createServer((request: IncomingMessage, response: ServerResponse) => {
209
+ void (async () => {
210
+ if (upstream === undefined) {
211
+ response.writeHead(500, { 'content-type': 'application/json' });
212
+ response.end(`${JSON.stringify({ error: 'no upstream configured for this provider' })}\n`);
213
+ return;
214
+ }
215
+ if (request.method !== 'POST' || request.url !== upstream.path) {
216
+ // Only the one path that spends tokens. A gateway forwarding anything
217
+ // else is a general proxy for somebody's API key.
218
+ response.writeHead(404, { 'content-type': 'application/json' });
219
+ response.end(`${JSON.stringify({ error: 'not a path this gateway forwards' })}\n`);
220
+ return;
221
+ }
222
+
223
+ const body = await readBody(request);
224
+ if (body === null) {
225
+ response.writeHead(413, { 'content-type': 'application/json' });
226
+ response.end(`${JSON.stringify({ error: 'request body too large' })}\n`);
227
+ return;
228
+ }
229
+
230
+ const described = describe(body, context.provider);
231
+ if (described === null) {
232
+ response.writeHead(400, { 'content-type': 'application/json' });
233
+ response.end(`${JSON.stringify({ error: 'could not read a model out of this request' })}\n`);
234
+ return;
235
+ }
236
+
237
+ const decision = gatewayDecision(
238
+ { provider: context.provider, ...described },
239
+ context.standing(),
240
+ { catalogue: context.catalogue, policy: context.policy },
241
+ );
242
+
243
+ if (decision.kind === 'refuse') {
244
+ /**
245
+ * **402, deliberately, and never 429.**
246
+ *
247
+ * Every provider SDK retries a 429 automatically — that is what the
248
+ * code means to them — so answering a budget refusal with one turns a
249
+ * single refusal into a retry storm against a gateway that will refuse
250
+ * every time. 402 Payment Required is both literally correct and in
251
+ * nobody's default retry list.
252
+ */
253
+ response.writeHead(402, { 'content-type': 'application/json' });
254
+ response.end(refusalBody(decision));
255
+ context.note(`refused ${described.model}: ${decision.reason}`);
256
+ return;
257
+ }
258
+
259
+ const outgoing = new Headers();
260
+ for (const [name, value] of Object.entries(request.headers)) {
261
+ if (HOP_BY_HOP.has(name.toLowerCase()) || value === undefined) continue;
262
+ outgoing.set(name, Array.isArray(value) ? value.join(', ') : value);
263
+ }
264
+
265
+ /**
266
+ * The body forwarded is the body received, **byte for byte**, except on
267
+ * a configured substitution — which replaces exactly one field and says
268
+ * so in the record.
269
+ */
270
+ let forwarded = body;
271
+ if (decision.kind === 'substitute') {
272
+ const parsed = JSON.parse(body) as Record<string, unknown>;
273
+ parsed.model = decision.to.id;
274
+ forwarded = JSON.stringify(parsed);
275
+ context.note(`substituted ${described.model} → ${decision.to.id}: ${decision.configuredReason}`);
276
+ } else if (decision.unjudged !== null) {
277
+ context.note(`forwarded unjudged (${decision.unjudged}): fail-open`);
278
+ }
279
+
280
+ let upstreamResponse: Response;
281
+ try {
282
+ upstreamResponse = await doFetch(`${upstream.origin}${upstream.path}`, {
283
+ method: 'POST',
284
+ headers: outgoing,
285
+ body: forwarded,
286
+ });
287
+ } catch (error) {
288
+ /**
289
+ * The upstream is unreachable. This is **not** a budget refusal and
290
+ * must not look like one: the caller needs to tell "your provider is
291
+ * down" from "you are out of money", and a proxy that blurs them sends
292
+ * somebody to fix the wrong thing.
293
+ */
294
+ response.writeHead(502, { 'content-type': 'application/json' });
295
+ response.end(
296
+ `${JSON.stringify({
297
+ error: { type: 'trazum_upstream_unreachable', message: error instanceof Error ? error.message : String(error) },
298
+ })}\n`,
299
+ );
300
+ return;
301
+ }
302
+
303
+ const text = await upstreamResponse.text();
304
+
305
+ // Measured at the moment of the call, from the provider's own counts —
306
+ // the reason this beats a connector, which reports the runaway after it
307
+ // ran. Counts only reach `record`; the body is dropped here.
308
+ let measured: unknown;
309
+ try {
310
+ measured = JSON.parse(text);
311
+ } catch {
312
+ measured = null;
313
+ }
314
+ const usage = usageFromResponse(context.provider, measured);
315
+ if (usage !== null) {
316
+ context.record({
317
+ model: decision.kind === 'substitute' ? decision.to.id : described.model,
318
+ label: described.label,
319
+ substituted: decision.kind === 'substitute',
320
+ ...usage,
321
+ });
322
+ }
323
+
324
+ const back: Record<string, string> = {};
325
+ upstreamResponse.headers.forEach((value, name) => {
326
+ if (!HOP_BY_HOP.has(name.toLowerCase())) back[name] = value;
327
+ });
328
+ response.writeHead(upstreamResponse.status, back);
329
+ response.end(text);
330
+ })();
331
+ });
332
+ }
333
+
334
+ export function listenGateway(
335
+ server: Server,
336
+ where: { port: number } | { socket: string },
337
+ ): Promise<string> {
338
+ return new Promise((resolve, reject) => {
339
+ server.once('error', reject);
340
+ if ('socket' in where) {
341
+ server.listen(where.socket, () => resolve(where.socket));
342
+ return;
343
+ }
344
+ server.listen(where.port, BIND_HOST, () => {
345
+ const address = server.address();
346
+ const port = typeof address === 'object' && address !== null ? address.port : where.port;
347
+ resolve(`http://${BIND_HOST}:${port}`);
348
+ });
349
+ });
350
+ }
package/src/i18n/en.ts CHANGED
@@ -57,6 +57,7 @@ ${bold('USAGE')}
57
57
  trazum conform <file|-> [--contract <name>]
58
58
  trazum models
59
59
  trazum rules
60
+ trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
60
61
  trazum feedback
61
62
  trazum --version
62
63
 
@@ -101,6 +102,22 @@ ${bold('OPTIONS FOR feedback')}
101
102
  anonymous counter, and a test fails the build if this command ever reaches
102
103
  the network.
103
104
 
105
+ ${bold('OPTIONS FOR gateway')}
106
+ --on-cannot-tell <policy> Required, no default: fail-open or fail-closed.
107
+ What happens when the gateway cannot judge a call
108
+ — no budget, nothing measured, an unpriced model.
109
+ --port <n> | --socket <p> Where to listen. Loopback only, always.
110
+
111
+ Stands between your SDK and the provider, speaking their wire format, so no
112
+ code changes. Usage is measured from the provider's own response as it comes
113
+ back — no export, no connector lag, no missing day.
114
+
115
+ It refuses and never substitutes: a call over budget gets HTTP 402 with the
116
+ cheaper alternatives named. Substitution happens only where you wrote it down
117
+ in spend.substitute, with your reason, and every substituted call is marked.
118
+
119
+ Your credential is forwarded untouched and never read. See docs/gateway.md.
120
+
104
121
  ${bold('OPTIONS FOR prune')}
105
122
  --cases <file> One input per line, or a JSON array. Required.
106
123
  --yes Actually spend the calls. Without it the estimate is
@@ -580,6 +597,13 @@ ${bold('CONFIG FILE')}
580
597
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — money budgets for
581
598
  "trazum profile", in dollars. A budgeted label with no calls in
582
599
  the log is reported as not measured, never as a pass
600
+ outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
601
+ your own vocabulary for what happened, and which of it counts as
602
+ a win. Both required: which words mean success is a judgement
603
+ about your product rather than your bill, and this tool has no
604
+ standing to make it. Use [] if none of them are successes. A
605
+ value in a log that "values" never declares is named as
606
+ undeclared, never counted as a failure
583
607
  waive [{ "gate": "maxUsd", "reason": "August migration", "until":
584
608
  "2026-09-15" }] — a gate failure decided about, on the record.
585
609
  All three fields required: a waiver with no end date is a
@@ -933,6 +957,32 @@ ${bold('EXAMPLES')}
933
957
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
934
958
  },
935
959
 
960
+ gateway: {
961
+ badProvider: (given, known) =>
962
+ given === ''
963
+ ? `Name the provider to stand in front of. Known: ${known}.`
964
+ : `"${given}" is not a provider this gateway speaks for. Known: ${known}.`,
965
+ needsPolicy: (policies) =>
966
+ `--on-cannot-tell is required, and there is no default: ${policies}. When the gateway cannot judge a call — no budget, nothing measured, an unpriced model — one of these happens, and only you know which failure your product can survive. fail-open keeps it working and lets the bill run; fail-closed stops the bill and takes it down with it. Picking one for you would be the most consequential decision in your architecture, made silently at install time.`,
967
+ listening: (where, provider) => `Gateway on ${where}, in front of ${provider}`,
968
+ pointYourSdk: (where) =>
969
+ `Point your SDK's base URL at ${where} and change nothing else. It speaks the provider's own wire format, so no code changes and no new client.`,
970
+ credential: () =>
971
+ 'Your credential is forwarded untouched and never read, never stored, never logged and never put in a URL. Trazum holds no key here and cannot make a call of its own through this.',
972
+ neverSubstitutes: () =>
973
+ 'A call over budget is refused with HTTP 402 and the cheaper alternatives named — never silently swapped, trimmed or downgraded. 402 rather than 429 on purpose: every provider SDK retries a 429, which would turn one refusal into a retry storm.',
974
+ standing: (consumed, limit) =>
975
+ `Judging against ${consumed} of ${limit}, measured, read once at start — a file read in the request path would put this tool's latency between you and your provider on every call.`,
976
+ noStanding: () =>
977
+ 'Nothing measured for this period, so every call is unjudged and the failure policy below decides. Set spend.monthlyUsd and pull with trazum connect.',
978
+ policy: (policy) =>
979
+ policy === 'fail-open'
980
+ ? 'When it cannot judge: the call goes through, and the record says it was unjudged rather than within budget.'
981
+ : 'When it cannot judge: the call is refused. Nothing gets through unmeasured.',
982
+ measured: (model, label, input, output, substituted) =>
983
+ ` ${model}${label === null ? '' : ` [${label}]`}: ${input} in, ${output} out${substituted ? ' (substituted — marked, and never counted as the call that was asked for)' : ''}`,
984
+ },
985
+
936
986
  feedback: {
937
987
  heading: () => 'Telling us something',
938
988
  sendsNothing: () =>
@@ -1588,6 +1638,25 @@ ${bold('EXAMPLES')}
1588
1638
  coverageHeading: () => 'What this log cannot answer yet',
1589
1639
  needsLabel: (seen) =>
1590
1640
  `"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.`,
1641
+ needsOutcome: (seen) =>
1642
+ `an "outcome" — ${seen}. The one field that changes what every other figure here means: without it this tool can say a workload got 40% cheaper and cannot say whether it stopped working. Record your own word for what happened and declare the vocabulary under "outcomes".`,
1643
+ dryRunOutcomes: (share) =>
1644
+ `cost per outcome and a success rate (${share} of records carry an "outcome")`,
1645
+ outcomeHeading: () => 'Outcomes',
1646
+ outcomeRate: (rate, ofUsd) =>
1647
+ `${rate} of ${ofUsd} in declared outcomes succeeded \u2014 by spend rather than by call, because the two diverge exactly when the expensive half is the half that fails.`,
1648
+ outcomeNoRate: (why) =>
1649
+ why === 'nothing-recorded'
1650
+ ? 'No success rate: nothing in this log recorded an outcome. That is not a rate of zero \u2014 a rate of zero is a real and terrible measurement, and this is nobody having told us.'
1651
+ : 'No success rate: "outcomes.success" declares no values, so nothing here counts as one. A legitimate thing to declare, and it means the rate is not this tool\u2019s to compute.',
1652
+ outcomeUnrecorded: (share, usd) =>
1653
+ `${share} of the bill (${usd}) carried no outcome, and is in neither half of the rate above.`,
1654
+ outcomeUndeclared: (values) =>
1655
+ `Not declared in "outcomes.values": ${values}. Named rather than counted as failures \u2014 a typo in an exporter should look like a typo, not like a product regression.`,
1656
+ outcomeColumns: { outcome: 'outcome', calls: 'calls', spend: 'spend' },
1657
+ verdictSuccess: () => 'success',
1658
+ verdictOther: () => '\u2014',
1659
+ verdictUndeclared: () => 'undeclared',
1591
1660
  needsSession: (seen) =>
1592
1661
  `"session" on ${seen} records: without it there is no conversation growth, no per-conversation cost, and no cache-TTL fit. It is grouped by and never printed.`,
1593
1662
  needsTs: (seen) =>
package/src/i18n/es.ts CHANGED
@@ -44,6 +44,7 @@ ${bold('USO')}
44
44
  trazum conform <fichero|-> [--contract <nombre>]
45
45
  trazum models
46
46
  trazum rules
47
+ trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
47
48
  trazum feedback
48
49
  trazum --version
49
50
 
@@ -89,6 +90,24 @@ ${bold('OPCIONES DE feedback')}
89
90
  contador anónimo, y una prueba hace fallar la compilación si este comando
90
91
  llega a tocar la red.
91
92
 
93
+ ${bold('OPCIONES DE gateway')}
94
+ --on-cannot-tell <política> Obligatorio, sin valor por defecto: fail-open o
95
+ fail-closed. Qué pasa cuando la pasarela no puede
96
+ juzgar una llamada — sin presupuesto, sin nada
97
+ medido, un modelo sin precio.
98
+ --port <n> | --socket <p> Dónde escuchar. Solo loopback, siempre.
99
+
100
+ Se pone entre tu SDK y el proveedor, hablando su formato, así que no hay
101
+ cambios de código. El consumo se mide desde la propia respuesta del proveedor
102
+ según llega — sin exportación, sin retraso, sin días que falten.
103
+
104
+ Rechaza y nunca sustituye: una llamada por encima del presupuesto recibe un
105
+ HTTP 402 con las alternativas más baratas nombradas. La sustitución solo
106
+ ocurre donde la escribiste en spend.substitute, con tu motivo, y toda llamada
107
+ sustituida queda marcada.
108
+
109
+ Tu credencial se reenvía intacta y nunca se lee. Ver docs/gateway.md.
110
+
92
111
  ${bold('OPCIONES DE prune')}
93
112
  --cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
94
113
  --yes Gasta las llamadas de verdad. Sin él se imprime la
@@ -598,6 +617,13 @@ ${bold('FICHERO DE CONFIGURACIÓN')}
598
617
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — presupuestos en
599
618
  dólares para "trazum profile". Una etiqueta con presupuesto y sin
600
619
  llamadas se informa como no medida, nunca como aprobada
620
+ outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
621
+ tu propio vocabulario para lo que pasó, y cuál de él cuenta como
622
+ acierto. Los dos son obligatorios: qué palabras significan éxito
623
+ es un juicio sobre tu producto y no sobre tu factura, y esta
624
+ herramienta no tiene potestad para hacerlo. Usa [] si ninguna lo
625
+ es. Un valor en un log que "values" no declare se nombra como
626
+ sin declarar, nunca se cuenta como fallo
601
627
  waive [{ "gate": "maxUsd", "reason": "migración de agosto", "until":
602
628
  "2026-09-15" }] — un fallo de gate sobre el que se ha decidido,
603
629
  registrado. Los tres campos son obligatorios: un waiver sin
@@ -967,6 +993,32 @@ ${bold('EJEMPLOS')}
967
993
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
968
994
  },
969
995
 
996
+ gateway: {
997
+ badProvider: (given, known) =>
998
+ given === ''
999
+ ? `Nombra el proveedor delante del que ponerse. Conocidos: ${known}.`
1000
+ : `"${given}" no es un proveedor por el que hable esta pasarela. Conocidos: ${known}.`,
1001
+ needsPolicy: (policies) =>
1002
+ `--on-cannot-tell es obligatorio, y no hay valor por defecto: ${policies}. Cuando la pasarela no puede juzgar una llamada — sin presupuesto, sin nada medido, un modelo sin precio — pasa una de las dos, y solo tú sabes qué fallo puede sobrevivir tu producto. fail-open lo mantiene funcionando y deja correr la factura; fail-closed para la factura y se lo lleva por delante. Elegir por ti sería tomar la decisión más consecuente de tu arquitectura, en silencio, al instalar.`,
1003
+ listening: (where, provider) => `Pasarela en ${where}, delante de ${provider}`,
1004
+ pointYourSdk: (where) =>
1005
+ `Apunta la URL base de tu SDK a ${where} y no cambies nada más. Habla el formato del propio proveedor, así que no hay cambios de código ni cliente nuevo.`,
1006
+ credential: () =>
1007
+ 'Tu credencial se reenvía intacta y nunca se lee, ni se guarda, ni se registra, ni se pone en una URL. Trazum no tiene ninguna clave aquí y no puede hacer una llamada propia a través de esto.',
1008
+ neverSubstitutes: () =>
1009
+ 'Una llamada por encima del presupuesto se rechaza con HTTP 402 y las alternativas más baratas nombradas — nunca se cambia, recorta ni degrada en silencio. 402 y no 429 a propósito: todos los SDK de proveedor reintentan un 429, lo que convertiría un rechazo en una tormenta de reintentos.',
1010
+ standing: (consumed, limit) =>
1011
+ `Juzgando contra ${consumed} de ${limit}, medido, leído una vez al arrancar — leer un fichero en el camino de la petición pondría la latencia de esta herramienta entre tú y tu proveedor en cada llamada.`,
1012
+ noStanding: () =>
1013
+ 'Nada medido para este periodo, así que ninguna llamada se juzga y decide la política de abajo. Configura spend.monthlyUsd y descarga con trazum connect.',
1014
+ policy: (policy) =>
1015
+ policy === 'fail-open'
1016
+ ? 'Cuando no puede juzgar: la llamada pasa, y el registro dice que no se juzgó, no que estaba dentro del presupuesto.'
1017
+ : 'Cuando no puede juzgar: la llamada se rechaza. Nada pasa sin medir.',
1018
+ measured: (model, label, input, output, substituted) =>
1019
+ ` ${model}${label === null ? '' : ` [${label}]`}: ${input} entrada, ${output} salida${substituted ? ' (sustituida — marcada, y nunca contada como la llamada que se pidió)' : ''}`,
1020
+ },
1021
+
970
1022
  feedback: {
971
1023
  heading: () => 'Contarnos algo',
972
1024
  sendsNothing: () =>
@@ -1619,6 +1671,25 @@ ${bold('EJEMPLOS')}
1619
1671
  coverageHeading: () => 'Lo que este registro todavía no puede responder',
1620
1672
  needsLabel: (seen) =>
1621
1673
  `"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.`,
1674
+ needsOutcome: (seen) =>
1675
+ `un "outcome" \u2014 ${seen}. El \u00fanico campo que cambia lo que significa cualquier otra cifra de aqu\u00ed: sin \u00e9l esta herramienta puede decir que una carga baj\u00f3 un 40% y no puede decir si dej\u00f3 de funcionar. Registra tu propia palabra para lo que pas\u00f3 y declara el vocabulario en "outcomes".`,
1676
+ dryRunOutcomes: (share) =>
1677
+ `coste por resultado y una tasa de \u00e9xito (${share} de los registros llevan "outcome")`,
1678
+ outcomeHeading: () => 'Resultados',
1679
+ outcomeRate: (rate, ofUsd) =>
1680
+ `${rate} de ${ofUsd} en resultados declarados tuvo \u00e9xito \u2014 por gasto y no por llamada, porque las dos cifras divergen justo cuando la mitad cara es la que falla.`,
1681
+ outcomeNoRate: (why) =>
1682
+ why === 'nothing-recorded'
1683
+ ? 'Sin tasa de \u00e9xito: nada en este log registr\u00f3 un resultado. Eso no es una tasa de cero \u2014 una tasa de cero es una medici\u00f3n real y p\u00e9sima, y esto es que nadie nos lo ha dicho.'
1684
+ : 'Sin tasa de \u00e9xito: "outcomes.success" no declara ning\u00fan valor, as\u00ed que aqu\u00ed nada cuenta como \u00e9xito. Es leg\u00edtimo declararlo as\u00ed, y significa que la tasa no le corresponde calcularla a esta herramienta.',
1685
+ outcomeUnrecorded: (share, usd) =>
1686
+ `${share} de la factura (${usd}) no llev\u00f3 resultado, y no est\u00e1 en ninguna de las dos mitades de la tasa de arriba.`,
1687
+ outcomeUndeclared: (values) =>
1688
+ `No declarados en "outcomes.values": ${values}. Nombrados en vez de contados como fallos \u2014 una errata en un exportador debe parecer una errata, no una regresi\u00f3n del producto.`,
1689
+ outcomeColumns: { outcome: 'resultado', calls: 'llamadas', spend: 'gasto' },
1690
+ verdictSuccess: () => '\u00e9xito',
1691
+ verdictOther: () => '\u2014',
1692
+ verdictUndeclared: () => 'sin declarar',
1622
1693
  needsSession: (seen) =>
1623
1694
  `"session" en ${seen} registros: sin él no hay crecimiento de conversación, ni coste por conversación, ni encaje del TTL de caché. Se agrupa por él y nunca se imprime.`,
1624
1695
  needsTs: (seen) =>
package/src/i18n/types.ts CHANGED
@@ -258,6 +258,25 @@ export interface CliMessages {
258
258
  * file a report is exactly the shape of a tool that phones home, and the
259
259
  * only way to be believed is to say so where somebody is looking.
260
260
  */
261
+ /**
262
+ * The proxy in the path. Every line here is either a promise about what it
263
+ * will not do, or the standing it is judging against — because a component
264
+ * between somebody and their provider is trusted on nothing but what it says
265
+ * plainly at start-up.
266
+ */
267
+ gateway: {
268
+ badProvider(given: string, known: string): string;
269
+ needsPolicy(policies: string): string;
270
+ listening(where: string, provider: string): string;
271
+ pointYourSdk(where: string): string;
272
+ credential(): string;
273
+ neverSubstitutes(): string;
274
+ standing(consumed: string, limit: string): string;
275
+ noStanding(): string;
276
+ policy(policy: string): string;
277
+ measured(model: string, label: string | null, input: number, output: number, substituted: boolean): string;
278
+ };
279
+
261
280
  feedback: {
262
281
  heading(): string;
263
282
  sendsNothing(): string;
@@ -1102,6 +1121,17 @@ export interface CliMessages {
1102
1121
  coverageHeading(): string;
1103
1122
  needsLabel(seen: string): string;
1104
1123
  needsSession(seen: string): string;
1124
+ needsOutcome(seen: string): string;
1125
+ dryRunOutcomes(share: string): string;
1126
+ outcomeHeading(): string;
1127
+ outcomeRate(rate: string, ofUsd: string): string;
1128
+ outcomeNoRate(why: string): string;
1129
+ outcomeUnrecorded(share: string, usd: string): string;
1130
+ outcomeUndeclared(values: string): string;
1131
+ outcomeColumns: { outcome: string; calls: string; spend: string };
1132
+ verdictSuccess(): string;
1133
+ verdictOther(): string;
1134
+ verdictUndeclared(): string;
1105
1135
  needsTs(seen: string): string;
1106
1136
  needsStopReason(seen: string): string;
1107
1137
  needsCacheTtl(seen: string): string;