@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.
- package/README.md +2 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +95 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +100 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +42 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +226 -1
- package/dist/index.js.map +1 -1
- package/dist/serve.d.ts +65 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +115 -0
- package/dist/serve.js.map +1 -0
- package/dist/watch-run.d.ts +69 -0
- package/dist/watch-run.d.ts.map +1 -0
- package/dist/watch-run.js +79 -0
- package/dist/watch-run.js.map +1 -0
- package/package.json +2 -2
- package/src/i18n/en.ts +110 -0
- package/src/i18n/es.ts +115 -0
- package/src/i18n/types.ts +44 -1
- package/src/index.ts +301 -0
- package/src/serve.ts +149 -0
- package/src/watch-run.ts +126 -0
package/dist/serve.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
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) =>
|