@trazum/cli 1.42.0 → 1.44.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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The endpoint that answers before the call is sent.
3
+ *
4
+ * **Loopback only, and the address is not a flag.** A cost oracle listening on
5
+ * a network interface is an attack surface with a very small upside: it holds
6
+ * a company's spend, its model mix and its budgets, and it answers anybody who
7
+ * asks. `checkedEndpoint` has guarded Trazum's *outbound* requests since 1.14
8
+ * on the principle that a caller selects an endpoint rather than naming one;
9
+ * this is the inbound counterpart, and it is enforced the same way — by there
10
+ * being no way to say otherwise. `127.0.0.1` is compiled in. A Unix socket is
11
+ * offered for callers that would rather not use a port at all.
12
+ *
13
+ * **No auth, on purpose.** Anything reachable only from the machine it runs on
14
+ * is already behind the operating system's own boundary, and a token checked
15
+ * over loopback is theatre: whoever can reach the socket can read the token
16
+ * out of the process that holds it. The honest posture is a surface small
17
+ * enough not to need one.
18
+ *
19
+ * **It degrades rather than failing.** With no store and no budget the
20
+ * endpoint still prices the call from the bundled catalogue and says the
21
+ * budget half is unknown. Offline is a mode, not an error, and an oracle that
22
+ * refuses to speak when half its inputs are missing is an oracle nobody wires
23
+ * into a hot path.
24
+ */
25
+ import type { Server } from 'node:http';
26
+ import type { PricingCatalogue } from '@trazum/core';
27
+ /** Compiled in. See the module note: this is the inbound SSRF posture. */
28
+ export declare const BIND_HOST = "127.0.0.1";
29
+ export declare const DEFAULT_PORT = 7317;
30
+ /**
31
+ * Bodies larger than this are refused unread.
32
+ *
33
+ * A prompt is text and text is unbounded; a hot-path oracle that will buffer
34
+ * whatever it is handed is a memory exhaustion away from taking the caller
35
+ * down with it — and the caller was asking how to spend *less*.
36
+ */
37
+ export declare const MAX_BODY_BYTES = 1000000;
38
+ export interface ServeContext {
39
+ catalogue: PricingCatalogue;
40
+ /**
41
+ * Measured spend and the budget it is judged against, read once at start
42
+ * and refreshed by the caller.
43
+ *
44
+ * Read once because the whole promise here is single-digit milliseconds,
45
+ * and a file read in the request path cannot make that promise. The staleness
46
+ * is a real cost, so the answer carries the window its measurement covers
47
+ * rather than implying it is current to the second.
48
+ */
49
+ position: () => {
50
+ consumedUsd?: number;
51
+ limitUsd?: number;
52
+ window?: {
53
+ fromMs: number;
54
+ toMs: number;
55
+ } | null;
56
+ };
57
+ }
58
+ export declare function buildServer(context: ServeContext): Server;
59
+ export interface ListenTarget {
60
+ /** A Unix socket path, when the caller would rather not use a port. */
61
+ socket?: string;
62
+ port?: number;
63
+ }
64
+ export declare function listen(server: Server, target: ListenTarget): Promise<string>;
65
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,OAAO,KAAK,EAAmB,MAAM,EAAkB,MAAM,WAAW,CAAC;AAEzE,OAAO,KAAK,EAAc,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEjE,0EAA0E;AAC1E,eAAO,MAAM,SAAS,cAAc,CAAC;AAErC,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,UAAY,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,gBAAgB,CAAC;IAC5B;;;;;;;;OAQG;IACH,QAAQ,EAAE,MAAM;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAE,CAAC;CAC/G;AAyBD,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CA8CzD;AAED,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAa5E"}
package/dist/serve.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The endpoint that answers before the call is sent.
3
+ *
4
+ * **Loopback only, and the address is not a flag.** A cost oracle listening on
5
+ * a network interface is an attack surface with a very small upside: it holds
6
+ * a company's spend, its model mix and its budgets, and it answers anybody who
7
+ * asks. `checkedEndpoint` has guarded Trazum's *outbound* requests since 1.14
8
+ * on the principle that a caller selects an endpoint rather than naming one;
9
+ * this is the inbound counterpart, and it is enforced the same way — by there
10
+ * being no way to say otherwise. `127.0.0.1` is compiled in. A Unix socket is
11
+ * offered for callers that would rather not use a port at all.
12
+ *
13
+ * **No auth, on purpose.** Anything reachable only from the machine it runs on
14
+ * is already behind the operating system's own boundary, and a token checked
15
+ * over loopback is theatre: whoever can reach the socket can read the token
16
+ * out of the process that holds it. The honest posture is a surface small
17
+ * enough not to need one.
18
+ *
19
+ * **It degrades rather than failing.** With no store and no budget the
20
+ * endpoint still prices the call from the bundled catalogue and says the
21
+ * budget half is unknown. Offline is a mode, not an error, and an oracle that
22
+ * refuses to speak when half its inputs are missing is an oracle nobody wires
23
+ * into a hot path.
24
+ */
25
+ import { createServer } from 'node:http';
26
+ import { answerCost } from '@trazum/core';
27
+ /** Compiled in. See the module note: this is the inbound SSRF posture. */
28
+ export const BIND_HOST = '127.0.0.1';
29
+ export const DEFAULT_PORT = 7317;
30
+ /**
31
+ * Bodies larger than this are refused unread.
32
+ *
33
+ * A prompt is text and text is unbounded; a hot-path oracle that will buffer
34
+ * whatever it is handed is a memory exhaustion away from taking the caller
35
+ * down with it — and the caller was asking how to spend *less*.
36
+ */
37
+ export const MAX_BODY_BYTES = 1_000_000;
38
+ async function readBody(request) {
39
+ const chunks = [];
40
+ let size = 0;
41
+ for await (const chunk of request) {
42
+ size += chunk.length;
43
+ if (size > MAX_BODY_BYTES)
44
+ throw new Error('body too large');
45
+ chunks.push(chunk);
46
+ }
47
+ return Buffer.concat(chunks).toString('utf8');
48
+ }
49
+ const send = (response, status, body) => {
50
+ const text = JSON.stringify(body);
51
+ response.writeHead(status, {
52
+ 'content-type': 'application/json',
53
+ 'content-length': Buffer.byteLength(text),
54
+ // Nothing here is for a browser to read across origins, and saying so
55
+ // costs one header.
56
+ 'cache-control': 'no-store',
57
+ });
58
+ response.end(text);
59
+ };
60
+ export function buildServer(context) {
61
+ return createServer((request, response) => {
62
+ void (async () => {
63
+ const url = new URL(request.url ?? '/', `http://${BIND_HOST}`);
64
+ if (request.method === 'GET' && url.pathname === '/health') {
65
+ send(response, 200, { ok: true, schemaVersion: 1 });
66
+ return;
67
+ }
68
+ if (request.method !== 'POST' || url.pathname !== '/cost') {
69
+ send(response, 404, {
70
+ error: 'not-found',
71
+ detail: 'POST /cost, or GET /health.',
72
+ });
73
+ return;
74
+ }
75
+ let payload;
76
+ try {
77
+ const raw = await readBody(request);
78
+ payload = raw.trim() === '' ? {} : JSON.parse(raw);
79
+ }
80
+ catch (error) {
81
+ send(response, 400, {
82
+ error: 'bad-request',
83
+ detail: error instanceof Error && error.message === 'body too large'
84
+ ? `A request body may be at most ${MAX_BODY_BYTES} bytes.`
85
+ : 'The body must be JSON.',
86
+ });
87
+ return;
88
+ }
89
+ const position = context.position();
90
+ const answer = answerCost({
91
+ model: typeof payload.model === 'string' ? payload.model : undefined,
92
+ inputTokens: typeof payload.inputTokens === 'number' ? payload.inputTokens : undefined,
93
+ outputTokens: typeof payload.outputTokens === 'number' ? payload.outputTokens : undefined,
94
+ basis: payload.basis === 'heuristic' ? 'heuristic' : 'token-count',
95
+ ...position,
96
+ }, { catalogue: context.catalogue });
97
+ send(response, 200, answer);
98
+ })();
99
+ });
100
+ }
101
+ export function listen(server, target) {
102
+ return new Promise((resolve, reject) => {
103
+ server.once('error', reject);
104
+ if (target.socket !== undefined) {
105
+ server.listen(target.socket, () => resolve(target.socket));
106
+ return;
107
+ }
108
+ // The host is not a parameter. See the module note.
109
+ server.listen(target.port ?? DEFAULT_PORT, BIND_HOST, () => {
110
+ const address = server.address();
111
+ resolve(typeof address === 'object' && address !== null ? `${BIND_HOST}:${address.port}` : String(address));
112
+ });
113
+ });
114
+ }
115
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * One cycle of watching, and the state that survives a restart.
3
+ *
4
+ * `--once` is the primitive: pull the window, keep it, evaluate the gates,
5
+ * emit what crossed, save state. A cron entry runs exactly that, and so does
6
+ * every test. The foreground loop is this function in a timer, so there is one
7
+ * code path and no daemon-only behaviour that nobody exercises.
8
+ *
9
+ * **The state file is what makes a restart honest.** Without it a resumed
10
+ * watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
11
+ * it was watching the whole time (a claim it cannot make). With it, the
12
+ * crossing stays quiet and the unwatched stretch gets named once.
13
+ */
14
+ import type { WatchCrossing } from '@trazum/core';
15
+ export declare const WATCH_STATE_FILE = ".trazum/watch.json";
16
+ export declare const WATCH_STATE_VERSION = 1;
17
+ export interface WatchState {
18
+ v: number;
19
+ /** When the last cycle ran, so a long silence can be told from a first run. */
20
+ lastCycleMs: number;
21
+ /** How far the measurements reached, for the coverage gap. */
22
+ lastCoveredToMs: number | null;
23
+ /** Gate keys already alerted on, so a restart is not amnesia. */
24
+ fired: Record<string, number>;
25
+ }
26
+ export declare function readWatchState(root: string): Promise<WatchState | null>;
27
+ export declare function writeWatchState(root: string, state: WatchState): Promise<void>;
28
+ /**
29
+ * Whether a webhook URL is one this tool will post to.
30
+ *
31
+ * **This is not the SSRF case and the difference matters.** `checkedEndpoint`
32
+ * exists because a *request body* must never name a host: an anonymous caller
33
+ * pointing a shared server at an internal address is somebody else's machine
34
+ * reaching somewhere it was never meant to. Here the URL is in the operator's
35
+ * own config, on their own machine, and pointing it at their own alerting
36
+ * daemon on loopback is the ordinary case rather than the attack.
37
+ *
38
+ * So loopback is allowed and plain http is allowed *only* there, while two
39
+ * rules stay absolute: no credentials embedded in the URL, because a URL ends
40
+ * up in logs and shell history; and https everywhere else, because an alert
41
+ * carries spend figures across a network.
42
+ */
43
+ export type WebhookRejection = 'invalid-url' | 'credentials-in-url' | 'insecure-scheme';
44
+ export declare function checkWebhook(raw: string): {
45
+ ok: true;
46
+ url: URL;
47
+ } | {
48
+ ok: false;
49
+ reason: WebhookRejection;
50
+ };
51
+ /**
52
+ * The alert payload.
53
+ *
54
+ * Figures and gate names, never prompt text — the store has never held any and
55
+ * neither does this. Every crossing carries its own provenance, so a receiver
56
+ * that fans these into a dashboard cannot lose track of what kind of number it
57
+ * is holding.
58
+ */
59
+ export interface WatchAlert {
60
+ schemaVersion: 1;
61
+ firedAtMs: number;
62
+ crossings: WatchCrossing[];
63
+ }
64
+ export declare function postWebhook(url: URL, alert: WatchAlert, fetchImpl?: typeof fetch): Promise<{
65
+ ok: boolean;
66
+ status: number | null;
67
+ error: string | null;
68
+ }>;
69
+ //# sourceMappingURL=watch-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-run.d.ts","sourceRoot":"","sources":["../src/watch-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,eAAO,MAAM,gBAAgB,uBAAuB,CAAC;AAErD,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAC;IACV,+EAA+E;IAC/E,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU7E;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAIpF;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,oBAAoB,GAAG,iBAAiB,CAAC;AAExF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,CAkB1G;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,CAAC,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAC/B,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,UAAU,EACjB,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAmBvE"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * One cycle of watching, and the state that survives a restart.
3
+ *
4
+ * `--once` is the primitive: pull the window, keep it, evaluate the gates,
5
+ * emit what crossed, save state. A cron entry runs exactly that, and so does
6
+ * every test. The foreground loop is this function in a timer, so there is one
7
+ * code path and no daemon-only behaviour that nobody exercises.
8
+ *
9
+ * **The state file is what makes a restart honest.** Without it a resumed
10
+ * watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
11
+ * it was watching the whole time (a claim it cannot make). With it, the
12
+ * crossing stays quiet and the unwatched stretch gets named once.
13
+ */
14
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { SAFE_FETCH_INIT } from '@trazum/core/node';
17
+ export const WATCH_STATE_FILE = '.trazum/watch.json';
18
+ export const WATCH_STATE_VERSION = 1;
19
+ export async function readWatchState(root) {
20
+ try {
21
+ const parsed = JSON.parse(await readFile(join(root, WATCH_STATE_FILE), 'utf8'));
22
+ if (parsed?.v !== WATCH_STATE_VERSION)
23
+ return null;
24
+ return parsed;
25
+ }
26
+ catch {
27
+ // No state, or state this version cannot read: a first cycle either way,
28
+ // which is a state the caller reports rather than an error.
29
+ return null;
30
+ }
31
+ }
32
+ export async function writeWatchState(root, state) {
33
+ const path = join(root, WATCH_STATE_FILE);
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
36
+ }
37
+ export function checkWebhook(raw) {
38
+ let url;
39
+ try {
40
+ url = new URL(raw);
41
+ }
42
+ catch {
43
+ return { ok: false, reason: 'invalid-url' };
44
+ }
45
+ if (url.username !== '' || url.password !== '') {
46
+ return { ok: false, reason: 'credentials-in-url' };
47
+ }
48
+ const loopback = url.hostname === 'localhost' ||
49
+ url.hostname === '127.0.0.1' ||
50
+ url.hostname === '[::1]' ||
51
+ url.hostname === '::1';
52
+ if (url.protocol === 'https:')
53
+ return { ok: true, url };
54
+ if (url.protocol === 'http:' && loopback)
55
+ return { ok: true, url };
56
+ return { ok: false, reason: 'insecure-scheme' };
57
+ }
58
+ export async function postWebhook(url, alert, fetchImpl = fetch) {
59
+ try {
60
+ const response = await fetchImpl(url.toString(), {
61
+ ...SAFE_FETCH_INIT,
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify(alert),
65
+ signal: AbortSignal.timeout(10_000),
66
+ });
67
+ return { ok: response.ok, status: response.status, error: null };
68
+ }
69
+ catch (error) {
70
+ /**
71
+ * A webhook that will not deliver must not take the alert down with it.
72
+ * The exit code and the stdout event have already carried the crossing;
73
+ * losing those because a receiver is down would make the quietest failure
74
+ * the loudest one.
75
+ */
76
+ return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
77
+ }
78
+ }
79
+ //# sourceMappingURL=watch-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-run.js","sourceRoot":"","sources":["../src/watch-run.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.42.0",
3
+ "version": "1.44.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.42.0"
40
+ "@trazum/core": "1.44.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -44,6 +44,8 @@ ${bold('USAGE')}
44
44
  trazum history <dir-of-stored-reports> [options]
45
45
  trazum connect <anthropic|openai> [options]
46
46
  trazum store [--prune] [options]
47
+ trazum watch [--once | --interval 15m] [options]
48
+ trazum serve [--port <n> | --socket <path>]
47
49
  trazum diff <before> <after> [options]
48
50
  trazum diff --all <dir> <dir> [options]
49
51
  trazum rank <dir> [options]
@@ -321,6 +323,58 @@ ${bold('OPTIONS FOR plan')}
321
323
  a plan that hides its assumptions is advice pretending to be arithmetic.
322
324
  Projected savings and money already spent are separate totals throughout.
323
325
 
326
+ ${bold('OPTIONS FOR serve')}
327
+ --port <n> Port on 127.0.0.1. Default: 7317.
328
+ --socket <path> Listen on a Unix socket instead of a port.
329
+
330
+ Answers the two questions that matter at call time — what will this cost,
331
+ and is there budget left — in single-digit milliseconds, so an agent or a
332
+ wrapper can ask before it spends rather than reading a report afterwards.
333
+
334
+ POST /cost with {"model": "...", "inputTokens": n, "outputTokens": n};
335
+ GET /health says it is up. Every answer keeps the measured half and the
336
+ estimated half apart: the budget consumed comes from the provider's billed
337
+ counts, the cost of the call being asked about is an estimate of something
338
+ that has not happened, and the verdict says which of the two it rests on.
339
+
340
+ It listens on 127.0.0.1 and nowhere else, and there is no flag to change
341
+ that: a cost oracle on a network interface holds a company's spend, its
342
+ model mix and its budgets, and answers whoever asks. There is no auth for
343
+ the same reason there is no --host — a token checked over loopback is
344
+ theatre, and the honest posture is a surface small enough not to need one.
345
+
346
+ With no store and no budget it still prices the call and says the budget
347
+ half is unknown. Offline is a mode, not a failure.
348
+
349
+ ${bold('OPTIONS FOR watch')}
350
+ --once One cycle: measure, keep, evaluate, emit,
351
+ remember. What a cron entry runs. The default.
352
+ --interval <n>m|h Stay in the foreground and repeat. Minimum five
353
+ minutes: usage APIs are rate limited, and a tight
354
+ loop is a way to get your own key throttled.
355
+ --webhook <url> POST the crossings somewhere. https only, except
356
+ loopback; a URL carrying credentials is refused,
357
+ because URLs end up in logs and shell history.
358
+ --payload <file> Evaluate a usage payload you already have,
359
+ instead of the store.
360
+ --json The cycle as data: crossings, abstentions, gap.
361
+
362
+ Evaluates the spend gates from your config — maxUsd, maxDayUsd,
363
+ maxCacheLossUsd — against what has been measured, and tells you the
364
+ afternoon it happens rather than three weeks later. Exits 1 when something
365
+ crossed, so cron mails it and CI fails.
366
+
367
+ An alert fires on a measured crossing and never on a projection: "you have
368
+ spent $412 of a $400 budget" is a fact and "you will exceed" is a forecast,
369
+ which this tool does not make at any window length. A day still being
370
+ measured is reported as not yet judgeable rather than passed — but a day
371
+ already over budget fires whatever the hour, because it does not become less
372
+ over budget at midnight.
373
+
374
+ A restart does not re-alert on a crossing already reported, and names the
375
+ stretch it was not watching, because a watcher that resumes in silence
376
+ implies coverage it did not have.
377
+
324
378
  ${bold('OPTIONS FOR store')}
325
379
  --prune Drop measurements older than the retention
326
380
  policy, and compact the append log to what the
@@ -1506,6 +1560,62 @@ ${bold('EXAMPLES')}
1506
1560
  `Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
1507
1561
  },
1508
1562
 
1563
+ serve: {
1564
+ listening: (where) => `Answering on ${where}`,
1565
+ loopbackOnly: () =>
1566
+ 'Loopback only, and there is no flag to change that: this holds your spend, your model mix and your budgets, and would answer whoever asked. There is no auth for the same reason — a token checked over loopback is theatre.',
1567
+ measuredFrom: (usd) =>
1568
+ `Budget answers are measured against ${usd} from the store, read once at start. Every answer carries the window that figure covers rather than implying it is current to the second — restart to refresh it.`,
1569
+ nothingMeasured: (dir) =>
1570
+ `Nothing is measured yet (the store at ${dir} is empty), so the budget half of every answer will say so. The cost half still answers from the catalogue: offline is a mode, not a failure.`,
1571
+ noBudget: () =>
1572
+ 'No spend.maxUsd is configured, so "is there budget left" has no subject and every answer says so rather than guessing one.',
1573
+ badPort: (value) => `"${value}" is not a port. Give a whole number from 0 to 65535, or use --socket.`,
1574
+ },
1575
+
1576
+ watch: {
1577
+ noThresholds: () =>
1578
+ 'Watching needs something to watch for. Set spend.maxUsd, spend.maxDayUsd or spend.maxCacheLossUsd in trazum.config.json — a watcher with no threshold is a green light nobody earned.',
1579
+ nothingToWatch: (dir) =>
1580
+ `Nothing has been measured yet: the store at ${dir} is empty. Fill it with "trazum connect <provider> --store" first — watching nothing would report that everything is fine.`,
1581
+ intervalTooTight: () =>
1582
+ '--interval must be at least 5m. Usage APIs are rate limited, and a tight loop is a way to get your own key throttled by a tool that exists to save you money.',
1583
+ badWebhook: (reason) =>
1584
+ reason === 'credentials-in-url'
1585
+ ? 'That webhook URL carries credentials. URLs end up in logs, shell history and error messages, so this one is refused — put the secret in a header your receiver checks, or in the receiver itself.'
1586
+ : reason === 'insecure-scheme'
1587
+ ? 'A webhook must be https, except on loopback. An alert carries your spend figures, and sending them in the clear across a network is a leak you did not ask for.'
1588
+ : 'That webhook is not a URL this tool can parse.',
1589
+ crossed: (gate, measured, limit, day) => {
1590
+ const what =
1591
+ gate === 'maxUsd'
1592
+ ? 'Total spend'
1593
+ : gate === 'maxDayUsd'
1594
+ ? `Spend on ${day}`
1595
+ : 'Money lost to caching';
1596
+ return `CROSSED — ${what} is ${measured} against a limit of ${limit}. Measured, not projected.`;
1597
+ },
1598
+ stillOver: (gate, measured, limit, day) => {
1599
+ const what =
1600
+ gate === 'maxUsd'
1601
+ ? 'Total spend'
1602
+ : gate === 'maxDayUsd'
1603
+ ? `Spend on ${day}`
1604
+ : 'Money lost to caching';
1605
+ return `STILL OVER — ${what} is ${measured} against a limit of ${limit}, and was already reported. Quiet is not clean.`;
1606
+ },
1607
+ notJudgeable: (gate, reason, covered) =>
1608
+ reason === 'window-too-short'
1609
+ ? `${gate} cannot be judged yet: this period is ${covered ?? 'partly'} measured, and a threshold over part of a day is a threshold over something else. Not a pass — it will be judged when the day is in.`
1610
+ : `${gate} cannot be judged on this source, which does not serve what the gate is written against. Not a pass: a gate silently skipped reads exactly like a gate that keeps passing.`,
1611
+ gap: (from, to) =>
1612
+ `Nothing was watching between ${from} and ${to}. Whatever crossed in that stretch was not seen, and this line exists so a resumed watcher does not imply coverage it did not have.`,
1613
+ allWithin: (gates) => `Within every threshold: ${gates} gates evaluated against measured spend.`,
1614
+ webhookFailed: (status) =>
1615
+ `The webhook did not deliver (${status}). The crossing is still in the exit code and in the output above — a receiver being down must not be the quietest failure in the room.`,
1616
+ watching: (minutes) => `Watching every ${minutes} minutes. Ctrl-C stops it.`,
1617
+ },
1618
+
1509
1619
  store: {
1510
1620
  appended: (count, dir) => `Kept ${count} measurements in ${dir}.`,
1511
1621
  empty: (dir) =>
package/src/i18n/es.ts CHANGED
@@ -31,6 +31,8 @@ ${bold('USO')}
31
31
  trazum history <dir-de-informes-guardados> [opciones]
32
32
  trazum connect <anthropic|openai> [opciones]
33
33
  trazum store [--prune] [opciones]
34
+ trazum watch [--once | --interval 15m] [opciones]
35
+ trazum serve [--port <n> | --socket <ruta>]
34
36
  trazum diff <antes> <después> [opciones]
35
37
  trazum diff --all <dir> <dir> [opciones]
36
38
  trazum rank <dir> [opciones]
@@ -328,6 +330,63 @@ ${bold('OPCIONES DE plan')}
328
330
  consejo haciéndose pasar por aritmética. El ahorro proyectado y el dinero ya
329
331
  gastado son totales separados en todas partes.
330
332
 
333
+ ${bold('OPCIONES DE serve')}
334
+ --port <n> Puerto en 127.0.0.1. Por defecto: 7317.
335
+ --socket <ruta> Escucha en un socket Unix en vez de un puerto.
336
+
337
+ Responde las dos preguntas que importan en el momento de la llamada — cuánto
338
+ va a costar esto y si queda presupuesto — en milisegundos de un solo dígito,
339
+ para que un agente o un envoltorio pueda preguntar antes de gastar en vez de
340
+ leer un informe después.
341
+
342
+ POST /cost con {"model": "...", "inputTokens": n, "outputTokens": n};
343
+ GET /health dice que está en pie. Cada respuesta mantiene separada la mitad
344
+ medida de la estimada: el presupuesto consumido viene de los recuentos que
345
+ facturó el proveedor, el coste de la llamada por la que preguntas es una
346
+ estimación de algo que no ha pasado, y el veredicto dice en cuál de las dos
347
+ se apoya.
348
+
349
+ Escucha en 127.0.0.1 y en ningún otro sitio, y no hay flag para cambiarlo:
350
+ un oráculo de costes en una interfaz de red guarda el gasto de una empresa,
351
+ su mezcla de modelos y sus presupuestos, y le responde a quien pregunte. No
352
+ hay autenticación por la misma razón por la que no hay --host — un token
353
+ comprobado sobre loopback es teatro, y la postura honesta es una superficie
354
+ lo bastante pequeña como para no necesitarlo.
355
+
356
+ Sin almacén y sin presupuesto sigue tasando la llamada y dice que la mitad
357
+ del presupuesto es desconocida. Sin conexión es un modo, no un fallo.
358
+
359
+ ${bold('OPCIONES DE watch')}
360
+ --once Una vuelta: medir, guardar, evaluar, emitir,
361
+ recordar. Lo que ejecuta una entrada de cron. Es
362
+ lo que se hace por defecto.
363
+ --interval <n>m|h Se queda en primer plano y repite. Mínimo cinco
364
+ minutos: las APIs de uso están limitadas por
365
+ tasa, y un bucle apretado es una forma de que te
366
+ estrangulen tu propia clave.
367
+ --webhook <url> Envía los cruces por POST. Solo https, salvo en
368
+ loopback; una URL con credenciales se rechaza,
369
+ porque las URLs acaban en logs e historiales.
370
+ --payload <fichero> Evalúa un payload de uso que ya tengas, en vez
371
+ del almacén.
372
+ --json La vuelta como datos: cruces, abstenciones, hueco.
373
+
374
+ Evalúa los gates de gasto de tu configuración — maxUsd, maxDayUsd,
375
+ maxCacheLossUsd — contra lo que se ha medido, y te lo dice la tarde en que
376
+ pasa en vez de tres semanas después. Sale con 1 cuando algo cruzó, así que
377
+ cron te lo manda y CI falla.
378
+
379
+ Una alerta salta por un cruce medido y nunca por una proyección: "has
380
+ gastado $412 de un presupuesto de $400" es un hecho y "vas a excederte" es un
381
+ pronóstico, que esta herramienta no hace a ninguna escala de ventana. Un día
382
+ que todavía se está midiendo se reporta como aún no juzgable en vez de
383
+ aprobarse — pero un día que ya se pasó del presupuesto salta a cualquier
384
+ hora, porque no se pasa menos a medianoche.
385
+
386
+ Un reinicio no vuelve a avisar de un cruce ya reportado, y nombra el tramo
387
+ que no estuvo vigilando, porque un vigilante que se reanuda en silencio
388
+ insinúa una cobertura que no tuvo.
389
+
331
390
  ${bold('OPCIONES DE store')}
332
391
  --prune Borra las mediciones más antiguas que la política
333
392
  de retención y compacta el log a lo que el
@@ -1531,6 +1590,62 @@ ${bold('EJEMPLOS')}
1531
1590
  `Plan escrito en ${path}, con fecha. Guárdalo: una predicción que nadie apuntó es una predicción que no se le puede exigir a nadie.`,
1532
1591
  },
1533
1592
 
1593
+ serve: {
1594
+ listening: (where) => `Respondiendo en ${where}`,
1595
+ loopbackOnly: () =>
1596
+ 'Solo loopback, y no hay flag para cambiarlo: esto guarda tu gasto, tu mezcla de modelos y tus presupuestos, y le respondería a quien preguntara. No hay autenticación por la misma razón — un token comprobado sobre loopback es teatro.',
1597
+ measuredFrom: (usd) =>
1598
+ `Las respuestas de presupuesto se miden contra ${usd} del almacén, leídos una vez al arrancar. Cada respuesta lleva el período que cubre esa cifra en vez de insinuar que está al segundo — reinicia para refrescarla.`,
1599
+ nothingMeasured: (dir) =>
1600
+ `Todavía no hay nada medido (el almacén de ${dir} está vacío), así que la mitad de presupuesto de cada respuesta lo dirá. La mitad del coste sigue respondiendo desde el catálogo: sin conexión es un modo, no un fallo.`,
1601
+ noBudget: () =>
1602
+ 'No hay spend.maxUsd configurado, así que "queda presupuesto" no tiene sujeto y cada respuesta lo dice en vez de inventarse uno.',
1603
+ badPort: (value) => `"${value}" no es un puerto. Da un número entero de 0 a 65535, o usa --socket.`,
1604
+ },
1605
+
1606
+ watch: {
1607
+ noThresholds: () =>
1608
+ 'Vigilar necesita algo que vigilar. Define spend.maxUsd, spend.maxDayUsd o spend.maxCacheLossUsd en trazum.config.json — un vigilante sin umbral es una luz verde que nadie se ha ganado.',
1609
+ nothingToWatch: (dir) =>
1610
+ `Todavía no se ha medido nada: el almacén de ${dir} está vacío. Llénalo primero con "trazum connect <proveedor> --store" — vigilar la nada reportaría que todo está bien.`,
1611
+ intervalTooTight: () =>
1612
+ '--interval tiene que ser de al menos 5m. Las APIs de uso están limitadas por tasa, y un bucle apretado es una forma de que una herramienta que existe para ahorrarte dinero acabe estrangulando tu propia clave.',
1613
+ badWebhook: (reason) =>
1614
+ reason === 'credentials-in-url'
1615
+ ? 'Esa URL de webhook lleva credenciales. Las URLs acaban en logs, historiales de shell y mensajes de error, así que se rechaza — pon el secreto en una cabecera que tu receptor compruebe, o en el propio receptor.'
1616
+ : reason === 'insecure-scheme'
1617
+ ? 'Un webhook tiene que ser https, salvo en loopback. Una alerta lleva tus cifras de gasto, y mandarlas en claro por una red es una fuga que no pediste.'
1618
+ : 'Ese webhook no es una URL que esta herramienta pueda parsear.',
1619
+ crossed: (gate, measured, limit, day) => {
1620
+ const what =
1621
+ gate === 'maxUsd'
1622
+ ? 'El gasto total'
1623
+ : gate === 'maxDayUsd'
1624
+ ? `El gasto del ${day}`
1625
+ : 'El dinero perdido con la caché';
1626
+ return `CRUZADO — ${what} es ${measured} contra un límite de ${limit}. Medido, no proyectado.`;
1627
+ },
1628
+ stillOver: (gate, measured, limit, day) => {
1629
+ const what =
1630
+ gate === 'maxUsd'
1631
+ ? 'El gasto total'
1632
+ : gate === 'maxDayUsd'
1633
+ ? `El gasto del ${day}`
1634
+ : 'El dinero perdido con la caché';
1635
+ return `SIGUE POR ENCIMA — ${what} es ${measured} contra un límite de ${limit}, y ya se avisó. Callado no es limpio.`;
1636
+ },
1637
+ notJudgeable: (gate, reason, covered) =>
1638
+ reason === 'window-too-short'
1639
+ ? `${gate} todavía no se puede juzgar: este período está medido al ${covered ?? 'parcialmente'}, y un umbral sobre parte de un día es un umbral sobre otra cosa. No es un aprobado — se juzgará cuando el día esté completo.`
1640
+ : `${gate} no se puede juzgar en esta fuente, que no sirve aquello sobre lo que está escrito el gate. No es un aprobado: un gate saltado en silencio se lee exactamente igual que un gate que lleva tiempo pasando.`,
1641
+ gap: (from, to) =>
1642
+ `Nada estuvo vigilando entre ${from} y ${to}. Lo que cruzara en ese tramo no se vio, y esta línea existe para que un vigilante reanudado no insinúe una cobertura que no tuvo.`,
1643
+ allWithin: (gates) => `Dentro de todos los umbrales: ${gates} gates evaluados contra gasto medido.`,
1644
+ webhookFailed: (status) =>
1645
+ `El webhook no se entregó (${status}). El cruce sigue en el código de salida y en la salida de arriba — que un receptor esté caído no puede ser el fallo más silencioso de la sala.`,
1646
+ watching: (minutes) => `Vigilando cada ${minutes} minutos. Ctrl-C lo para.`,
1647
+ },
1648
+
1534
1649
  store: {
1535
1650
  appended: (count, dir) => `Guardadas ${count} mediciones en ${dir}.`,
1536
1651
  empty: (dir) =>