@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/src/i18n/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CannotTellReason, EvalVerdict, Locale, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome } from '@trazum/core';
|
|
1
|
+
import type { CannotTellReason, EvalVerdict, Locale, NotJudgeable, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome, WatchGate } from '@trazum/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The CLI's own message catalogue.
|
|
@@ -1072,6 +1072,49 @@ export interface CliMessages {
|
|
|
1072
1072
|
wrote(path: string): string;
|
|
1073
1073
|
};
|
|
1074
1074
|
|
|
1075
|
+
/**
|
|
1076
|
+
* `trazum serve` — the answer given before the call is sent.
|
|
1077
|
+
*
|
|
1078
|
+
* The copy states the two things a reader must not have to infer: that this
|
|
1079
|
+
* listens on loopback and nowhere else, and that the measured half is read
|
|
1080
|
+
* once rather than being current to the second.
|
|
1081
|
+
*/
|
|
1082
|
+
serve: {
|
|
1083
|
+
listening(where: string): string;
|
|
1084
|
+
/** Why there is no --host, and why that is not an omission. */
|
|
1085
|
+
loopbackOnly(): string;
|
|
1086
|
+
measuredFrom(usd: string): string;
|
|
1087
|
+
nothingMeasured(dir: string): string;
|
|
1088
|
+
noBudget(): string;
|
|
1089
|
+
badPort(value: string): string;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* `trazum watch` — the gates, evaluated as the money moves.
|
|
1094
|
+
*
|
|
1095
|
+
* The copy here carries the rule that makes an alert at 3am trustworthy: a
|
|
1096
|
+
* crossing is measured, never projected, and a period too short to judge is
|
|
1097
|
+
* said to be rather than passed.
|
|
1098
|
+
*/
|
|
1099
|
+
watch: {
|
|
1100
|
+
/** Watching with no threshold configured is a green light nobody earned. */
|
|
1101
|
+
noThresholds(): string;
|
|
1102
|
+
nothingToWatch(dir: string): string;
|
|
1103
|
+
intervalTooTight(): string;
|
|
1104
|
+
badWebhook(reason: 'invalid-url' | 'credentials-in-url' | 'insecure-scheme'): string;
|
|
1105
|
+
/** A measured crossing. `day` names the afternoon when the gate is a day gate. */
|
|
1106
|
+
crossed(gate: WatchGate, measured: string, limit: string, day: string | null): string;
|
|
1107
|
+
/** Still over the limit, and already reported — quiet, but not clean. */
|
|
1108
|
+
stillOver(gate: WatchGate, measured: string, limit: string, day: string | null): string;
|
|
1109
|
+
/** Neither a pass nor a failure: this cannot be judged yet, and why. */
|
|
1110
|
+
notJudgeable(gate: WatchGate, reason: NotJudgeable, covered: string | null): string;
|
|
1111
|
+
/** The stretch nobody was watching, named rather than implied away. */
|
|
1112
|
+
gap(from: string, to: string): string;
|
|
1113
|
+
allWithin(gates: string): string;
|
|
1114
|
+
webhookFailed(status: string): string;
|
|
1115
|
+
watching(minutes: string): string;
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1075
1118
|
/**
|
|
1076
1119
|
* `trazum store` — the measurements kept on disk.
|
|
1077
1120
|
*
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
normalizeAnthropicUsage,
|
|
21
21
|
normalizeOpenAIUsage,
|
|
22
22
|
bucketsFromRecords,
|
|
23
|
+
evaluateWatch,
|
|
24
|
+
firedKey,
|
|
23
25
|
pruneRecords,
|
|
24
26
|
recordsFromBuckets,
|
|
25
27
|
storeInventory,
|
|
@@ -146,6 +148,14 @@ import {
|
|
|
146
148
|
import type { Revision } from './git.js';
|
|
147
149
|
import { fetchProviderUsage } from './connect.js';
|
|
148
150
|
import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
|
|
151
|
+
import { DEFAULT_PORT, buildServer, listen } from './serve.js';
|
|
152
|
+
import {
|
|
153
|
+
WATCH_STATE_VERSION,
|
|
154
|
+
checkWebhook,
|
|
155
|
+
postWebhook,
|
|
156
|
+
readWatchState,
|
|
157
|
+
writeWatchState,
|
|
158
|
+
} from './watch-run.js';
|
|
149
159
|
import { detectLocale, getCliMessages } from './i18n/index.js';
|
|
150
160
|
import {
|
|
151
161
|
MAX_SUMMARY_CHARS,
|
|
@@ -196,6 +206,10 @@ const VALUE_FLAGS = new Set([
|
|
|
196
206
|
'min-usd',
|
|
197
207
|
'payload',
|
|
198
208
|
'keep',
|
|
209
|
+
'interval',
|
|
210
|
+
'webhook',
|
|
211
|
+
'port',
|
|
212
|
+
'socket',
|
|
199
213
|
// `route` takes a path here, and the flag is deliberately not `--prompt`:
|
|
200
214
|
// everywhere else in this tool `--prompt` names a marked prompt *inside* a
|
|
201
215
|
// source file, and reusing it for a path would be a trap laid for the reader.
|
|
@@ -501,6 +515,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
501
515
|
history: ['store', 'json', 'markdown-out'],
|
|
502
516
|
connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
|
|
503
517
|
store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
|
|
518
|
+
watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
|
|
519
|
+
serve: ['port', 'socket', 'pricing', 'pricing-live'],
|
|
504
520
|
route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
|
|
505
521
|
eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
|
|
506
522
|
prune: ['cases', 'concurrency', 'json', 'yes'],
|
|
@@ -2282,6 +2298,285 @@ function parseWhen(
|
|
|
2282
2298
|
throw new Error(t.profile.badWhen(flag, value));
|
|
2283
2299
|
}
|
|
2284
2300
|
|
|
2301
|
+
/**
|
|
2302
|
+
* `trazum serve` — the answer in milliseconds.
|
|
2303
|
+
*
|
|
2304
|
+
* The measured position is read once at start rather than per request: the
|
|
2305
|
+
* whole promise is a single-digit-millisecond answer, and a file read in the
|
|
2306
|
+
* hot path cannot make it. That staleness is real, so every answer carries the
|
|
2307
|
+
* window its measurement covers instead of implying it is current to the
|
|
2308
|
+
* second.
|
|
2309
|
+
*/
|
|
2310
|
+
async function commandServe(
|
|
2311
|
+
args: Args,
|
|
2312
|
+
config: TrazumConfig,
|
|
2313
|
+
pricing: PricingCatalogue,
|
|
2314
|
+
t: CliMessages,
|
|
2315
|
+
): Promise<void> {
|
|
2316
|
+
const root = process.cwd();
|
|
2317
|
+
const limitUsd = config.spend?.maxUsd;
|
|
2318
|
+
|
|
2319
|
+
const { resolved } = await readStore(root);
|
|
2320
|
+
const measured = resolved.records.length > 0;
|
|
2321
|
+
/**
|
|
2322
|
+
* The window the measurement covers, carried into every answer.
|
|
2323
|
+
*
|
|
2324
|
+
* The position is read once at start, so a caller has to be able to see how
|
|
2325
|
+
* old it is. A null window here would let a figure from last month read as
|
|
2326
|
+
* current, which is the staleness this endpoint is otherwise honest about.
|
|
2327
|
+
*/
|
|
2328
|
+
const window = measured
|
|
2329
|
+
? {
|
|
2330
|
+
fromMs: Math.min(...resolved.records.map((record) => record.fromMs)),
|
|
2331
|
+
toMs: Math.max(...resolved.records.map((record) => record.toMs)),
|
|
2332
|
+
}
|
|
2333
|
+
: null;
|
|
2334
|
+
const report = bucketedProfile(
|
|
2335
|
+
{
|
|
2336
|
+
provider: 'store',
|
|
2337
|
+
granularity: 'bucketed',
|
|
2338
|
+
buckets: bucketsFromRecords(resolved.records),
|
|
2339
|
+
window,
|
|
2340
|
+
gaps: [],
|
|
2341
|
+
unavailable: [],
|
|
2342
|
+
},
|
|
2343
|
+
{ catalogue: pricing },
|
|
2344
|
+
);
|
|
2345
|
+
|
|
2346
|
+
const server = buildServer({
|
|
2347
|
+
catalogue: pricing,
|
|
2348
|
+
position: () => ({
|
|
2349
|
+
consumedUsd: measured ? report.total.totalUsd : undefined,
|
|
2350
|
+
limitUsd,
|
|
2351
|
+
window: report.span,
|
|
2352
|
+
}),
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
const socket = stringFlag(args, 'socket');
|
|
2356
|
+
const portRaw = stringFlag(args, 'port');
|
|
2357
|
+
const port = portRaw === undefined ? DEFAULT_PORT : Number(portRaw);
|
|
2358
|
+
if (socket === undefined && (!Number.isInteger(port) || port < 0 || port > 65_535)) {
|
|
2359
|
+
throw new Error(t.serve.badPort(String(portRaw)));
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const where = await listen(server, socket !== undefined ? { socket } : { port });
|
|
2363
|
+
console.log(c.bold(t.serve.listening(where)));
|
|
2364
|
+
console.log(` ${c.dim(wrap(t.serve.loopbackOnly(), 74, ' '))}`);
|
|
2365
|
+
console.log(
|
|
2366
|
+
` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(report.total.totalUsd)) : t.serve.nothingMeasured(STORE_DIR), 74, ' '))}`,
|
|
2367
|
+
);
|
|
2368
|
+
if (limitUsd === undefined) {
|
|
2369
|
+
console.log(` ${c.dim(wrap(t.serve.noBudget(), 74, ' '))}`);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* `trazum watch` — the afternoon it happened, said that afternoon.
|
|
2375
|
+
*
|
|
2376
|
+
* One cycle is the primitive: measure, keep, evaluate, emit, remember. The
|
|
2377
|
+
* loop is that cycle in a timer, so a cron entry and a foreground watcher run
|
|
2378
|
+
* exactly the same code and the tests exercise the thing that ships.
|
|
2379
|
+
*
|
|
2380
|
+
* Three transports, all boring on purpose: a non-zero exit code so cron mails
|
|
2381
|
+
* it, a JSON event on stdout so any pipeline can read it, and a webhook for
|
|
2382
|
+
* the operator who already has somewhere for alerts to go. No hosted service
|
|
2383
|
+
* and no account.
|
|
2384
|
+
*/
|
|
2385
|
+
async function commandWatch(
|
|
2386
|
+
args: Args,
|
|
2387
|
+
config: TrazumConfig,
|
|
2388
|
+
pricing: PricingCatalogue,
|
|
2389
|
+
t: CliMessages,
|
|
2390
|
+
): Promise<void> {
|
|
2391
|
+
const root = process.cwd();
|
|
2392
|
+
const asJson = boolFlag(args, 'json');
|
|
2393
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2394
|
+
const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
|
|
2395
|
+
|
|
2396
|
+
const thresholds = {
|
|
2397
|
+
maxUsd: config.spend?.maxUsd,
|
|
2398
|
+
maxDayUsd: config.spend?.maxDayUsd,
|
|
2399
|
+
maxCacheLossUsd: config.spend?.maxCacheLossUsd,
|
|
2400
|
+
};
|
|
2401
|
+
if (
|
|
2402
|
+
thresholds.maxUsd === undefined &&
|
|
2403
|
+
thresholds.maxDayUsd === undefined &&
|
|
2404
|
+
thresholds.maxCacheLossUsd === undefined
|
|
2405
|
+
) {
|
|
2406
|
+
throw new Error(t.watch.noThresholds());
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* A webhook is a new outbound surface, so it is checked before anything is
|
|
2411
|
+
* sent: credentials in a URL end up in logs and shell history, and an alert
|
|
2412
|
+
* carrying spend figures over plain http across a network is a leak the
|
|
2413
|
+
* operator did not ask for. Loopback is the exception, because pointing a
|
|
2414
|
+
* watcher at your own alerting daemon is the ordinary case.
|
|
2415
|
+
*/
|
|
2416
|
+
const webhookRaw = stringFlag(args, 'webhook');
|
|
2417
|
+
let webhook: URL | null = null;
|
|
2418
|
+
if (webhookRaw !== undefined) {
|
|
2419
|
+
const checked = checkWebhook(webhookRaw);
|
|
2420
|
+
if (!checked.ok) throw new Error(t.watch.badWebhook(checked.reason));
|
|
2421
|
+
webhook = checked.url;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const intervalRaw = stringFlag(args, 'interval');
|
|
2425
|
+
const once = boolFlag(args, 'once') || intervalRaw === undefined;
|
|
2426
|
+
let intervalMs = 0;
|
|
2427
|
+
if (!once) {
|
|
2428
|
+
const match = /^(\d+)(m|h)$/.exec(intervalRaw!);
|
|
2429
|
+
const amount = match === null ? NaN : Number(match[1]);
|
|
2430
|
+
intervalMs = match?.[2] === 'h' ? amount * 3_600_000 : amount * 60_000;
|
|
2431
|
+
// Usage APIs are rate limited, and a tight loop is a way to get somebody's
|
|
2432
|
+
// key throttled by a tool that was supposed to save them money.
|
|
2433
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 5 * 60_000) {
|
|
2434
|
+
throw new Error(t.watch.intervalTooTight());
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
const cycle = async (): Promise<number> => {
|
|
2439
|
+
const state = await readWatchState(root);
|
|
2440
|
+
const nowMs = Date.now();
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Where the measurements come from: a saved payload when one is named
|
|
2444
|
+
* (which is how this is tested and how an air-gapped run works), and the
|
|
2445
|
+
* store otherwise. A cycle that found nothing to measure says so — a
|
|
2446
|
+
* watcher over nothing is a green light nobody earned.
|
|
2447
|
+
*/
|
|
2448
|
+
const payloadPath = stringFlag(args, 'payload');
|
|
2449
|
+
let pull;
|
|
2450
|
+
if (payloadPath !== undefined) {
|
|
2451
|
+
pull = normalizeAnthropicUsage(JSON.parse(await readFile(payloadPath, 'utf8')));
|
|
2452
|
+
} else {
|
|
2453
|
+
const { resolved } = await readStore(root);
|
|
2454
|
+
if (resolved.records.length === 0) throw new Error(t.watch.nothingToWatch(STORE_DIR));
|
|
2455
|
+
pull = {
|
|
2456
|
+
provider: 'store',
|
|
2457
|
+
granularity: 'bucketed' as const,
|
|
2458
|
+
buckets: bucketsFromRecords(resolved.records),
|
|
2459
|
+
window: null,
|
|
2460
|
+
gaps: [],
|
|
2461
|
+
unavailable: [],
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
const report = bucketedProfile(pull, { catalogue: pricing });
|
|
2466
|
+
const cache = bucketedCacheEconomics(report);
|
|
2467
|
+
const result = evaluateWatch({
|
|
2468
|
+
report,
|
|
2469
|
+
thresholds,
|
|
2470
|
+
cacheDeltaUsd: cache.verdict === 'no-cache' ? undefined : cache.deltaUsd,
|
|
2471
|
+
nowMs,
|
|
2472
|
+
lastCoveredToMs: state?.lastCoveredToMs ?? undefined,
|
|
2473
|
+
alreadyFired: new Set(Object.keys(state?.fired ?? {})),
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
if (asJson) {
|
|
2477
|
+
console.log(JSON.stringify({ schemaVersion: 1, firedAtMs: nowMs, ...result }, null, 2));
|
|
2478
|
+
} else {
|
|
2479
|
+
if (result.gap !== null) {
|
|
2480
|
+
console.log(c.yellow(wrap(t.watch.gap(day(result.gap.fromMs), day(result.gap.toMs)), 76, ' ')));
|
|
2481
|
+
}
|
|
2482
|
+
for (const crossing of result.crossings) {
|
|
2483
|
+
console.log(
|
|
2484
|
+
c.red(
|
|
2485
|
+
wrap(
|
|
2486
|
+
t.watch.crossed(
|
|
2487
|
+
crossing.gate,
|
|
2488
|
+
formatUsd(crossing.measuredUsd),
|
|
2489
|
+
formatUsd(crossing.limitUsd),
|
|
2490
|
+
crossing.day,
|
|
2491
|
+
),
|
|
2492
|
+
76,
|
|
2493
|
+
' ',
|
|
2494
|
+
),
|
|
2495
|
+
),
|
|
2496
|
+
);
|
|
2497
|
+
}
|
|
2498
|
+
for (const abstention of result.abstentions) {
|
|
2499
|
+
console.log(
|
|
2500
|
+
c.dim(
|
|
2501
|
+
wrap(
|
|
2502
|
+
t.watch.notJudgeable(
|
|
2503
|
+
abstention.gate,
|
|
2504
|
+
abstention.reason,
|
|
2505
|
+
abstention.detail === null
|
|
2506
|
+
? null
|
|
2507
|
+
: `${Math.round((abstention.detail.coveredMs / abstention.detail.neededMs) * 100)}%`,
|
|
2508
|
+
),
|
|
2509
|
+
76,
|
|
2510
|
+
' ',
|
|
2511
|
+
),
|
|
2512
|
+
),
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
for (const still of result.suppressed) {
|
|
2516
|
+
console.log(
|
|
2517
|
+
c.yellow(
|
|
2518
|
+
wrap(
|
|
2519
|
+
t.watch.stillOver(
|
|
2520
|
+
still.gate,
|
|
2521
|
+
formatUsd(still.measuredUsd),
|
|
2522
|
+
formatUsd(still.limitUsd),
|
|
2523
|
+
still.day,
|
|
2524
|
+
),
|
|
2525
|
+
76,
|
|
2526
|
+
' ',
|
|
2527
|
+
),
|
|
2528
|
+
),
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
2531
|
+
if (
|
|
2532
|
+
result.crossings.length === 0 &&
|
|
2533
|
+
result.suppressed.length === 0 &&
|
|
2534
|
+
result.abstentions.length === 0
|
|
2535
|
+
) {
|
|
2536
|
+
console.log(c.green(wrap(t.watch.allWithin(n(Object.keys(thresholds).filter((k) => thresholds[k as keyof typeof thresholds] !== undefined).length)), 76, ' ')));
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
if (webhook !== null && result.crossings.length > 0) {
|
|
2541
|
+
const sent = await postWebhook(webhook, {
|
|
2542
|
+
schemaVersion: 1,
|
|
2543
|
+
firedAtMs: nowMs,
|
|
2544
|
+
crossings: result.crossings,
|
|
2545
|
+
});
|
|
2546
|
+
if (!sent.ok) {
|
|
2547
|
+
// Reported and swallowed: the exit code and the event already carried
|
|
2548
|
+
// the crossing, and losing those because a receiver is down would make
|
|
2549
|
+
// the quietest failure the loudest one.
|
|
2550
|
+
console.error(c.yellow(t.watch.webhookFailed(sent.status === null ? sent.error ?? '' : String(sent.status))));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
const fired = { ...(state?.fired ?? {}) };
|
|
2555
|
+
for (const crossing of result.crossings) fired[firedKey(crossing.gate, crossing.day)] = nowMs;
|
|
2556
|
+
await writeWatchState(root, {
|
|
2557
|
+
v: WATCH_STATE_VERSION,
|
|
2558
|
+
lastCycleMs: nowMs,
|
|
2559
|
+
lastCoveredToMs: report.span?.toMs ?? state?.lastCoveredToMs ?? null,
|
|
2560
|
+
fired,
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
return result.crossings.length + result.suppressed.length;
|
|
2564
|
+
};
|
|
2565
|
+
|
|
2566
|
+
const crossed = await cycle();
|
|
2567
|
+
// Still over is still a failure: only the alert was already sent.
|
|
2568
|
+
if (crossed > 0) process.exitCode = 1;
|
|
2569
|
+
if (once) return;
|
|
2570
|
+
|
|
2571
|
+
console.log(c.dim(t.watch.watching(String(Math.round(intervalMs / 60_000)))));
|
|
2572
|
+
// The loop is the cycle in a timer and nothing more, so the primitive above
|
|
2573
|
+
// is the only thing that ever needs testing.
|
|
2574
|
+
for (;;) {
|
|
2575
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
2576
|
+
await cycle();
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2285
2580
|
/**
|
|
2286
2581
|
* `trazum store` — what is kept, and what a prune would take.
|
|
2287
2582
|
*
|
|
@@ -6866,6 +7161,12 @@ async function main(): Promise<void> {
|
|
|
6866
7161
|
case 'store':
|
|
6867
7162
|
await commandStore(args, config, pricing, t);
|
|
6868
7163
|
break;
|
|
7164
|
+
case 'watch':
|
|
7165
|
+
await commandWatch(args, config, pricing, t);
|
|
7166
|
+
break;
|
|
7167
|
+
case 'serve':
|
|
7168
|
+
await commandServe(args, config, pricing, t);
|
|
7169
|
+
break;
|
|
6869
7170
|
case 'route':
|
|
6870
7171
|
await commandRoute(args, pricing, t);
|
|
6871
7172
|
break;
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
|
|
26
|
+
import { createServer } from 'node:http';
|
|
27
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
28
|
+
import { answerCost } from '@trazum/core';
|
|
29
|
+
import type { CostAnswer, PricingCatalogue } from '@trazum/core';
|
|
30
|
+
|
|
31
|
+
/** Compiled in. See the module note: this is the inbound SSRF posture. */
|
|
32
|
+
export const BIND_HOST = '127.0.0.1';
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_PORT = 7317;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bodies larger than this are refused unread.
|
|
38
|
+
*
|
|
39
|
+
* A prompt is text and text is unbounded; a hot-path oracle that will buffer
|
|
40
|
+
* whatever it is handed is a memory exhaustion away from taking the caller
|
|
41
|
+
* down with it — and the caller was asking how to spend *less*.
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_BODY_BYTES = 1_000_000;
|
|
44
|
+
|
|
45
|
+
export interface ServeContext {
|
|
46
|
+
catalogue: PricingCatalogue;
|
|
47
|
+
/**
|
|
48
|
+
* Measured spend and the budget it is judged against, read once at start
|
|
49
|
+
* and refreshed by the caller.
|
|
50
|
+
*
|
|
51
|
+
* Read once because the whole promise here is single-digit milliseconds,
|
|
52
|
+
* and a file read in the request path cannot make that promise. The staleness
|
|
53
|
+
* is a real cost, so the answer carries the window its measurement covers
|
|
54
|
+
* rather than implying it is current to the second.
|
|
55
|
+
*/
|
|
56
|
+
position: () => { consumedUsd?: number; limitUsd?: number; window?: { fromMs: number; toMs: number } | null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readBody(request: IncomingMessage): Promise<string> {
|
|
60
|
+
const chunks: Buffer[] = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
for await (const chunk of request) {
|
|
63
|
+
size += chunk.length;
|
|
64
|
+
if (size > MAX_BODY_BYTES) throw new Error('body too large');
|
|
65
|
+
chunks.push(chunk as Buffer);
|
|
66
|
+
}
|
|
67
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const send = (response: ServerResponse, status: number, body: unknown): void => {
|
|
71
|
+
const text = JSON.stringify(body);
|
|
72
|
+
response.writeHead(status, {
|
|
73
|
+
'content-type': 'application/json',
|
|
74
|
+
'content-length': Buffer.byteLength(text),
|
|
75
|
+
// Nothing here is for a browser to read across origins, and saying so
|
|
76
|
+
// costs one header.
|
|
77
|
+
'cache-control': 'no-store',
|
|
78
|
+
});
|
|
79
|
+
response.end(text);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function buildServer(context: ServeContext): Server {
|
|
83
|
+
return createServer((request, response) => {
|
|
84
|
+
void (async () => {
|
|
85
|
+
const url = new URL(request.url ?? '/', `http://${BIND_HOST}`);
|
|
86
|
+
|
|
87
|
+
if (request.method === 'GET' && url.pathname === '/health') {
|
|
88
|
+
send(response, 200, { ok: true, schemaVersion: 1 });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (request.method !== 'POST' || url.pathname !== '/cost') {
|
|
93
|
+
send(response, 404, {
|
|
94
|
+
error: 'not-found',
|
|
95
|
+
detail: 'POST /cost, or GET /health.',
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let payload: Record<string, unknown>;
|
|
101
|
+
try {
|
|
102
|
+
const raw = await readBody(request);
|
|
103
|
+
payload = raw.trim() === '' ? {} : (JSON.parse(raw) as Record<string, unknown>);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
send(response, 400, {
|
|
106
|
+
error: 'bad-request',
|
|
107
|
+
detail: error instanceof Error && error.message === 'body too large'
|
|
108
|
+
? `A request body may be at most ${MAX_BODY_BYTES} bytes.`
|
|
109
|
+
: 'The body must be JSON.',
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const position = context.position();
|
|
115
|
+
const answer: CostAnswer = answerCost(
|
|
116
|
+
{
|
|
117
|
+
model: typeof payload.model === 'string' ? payload.model : undefined,
|
|
118
|
+
inputTokens: typeof payload.inputTokens === 'number' ? payload.inputTokens : undefined,
|
|
119
|
+
outputTokens: typeof payload.outputTokens === 'number' ? payload.outputTokens : undefined,
|
|
120
|
+
basis: payload.basis === 'heuristic' ? 'heuristic' : 'token-count',
|
|
121
|
+
...position,
|
|
122
|
+
},
|
|
123
|
+
{ catalogue: context.catalogue },
|
|
124
|
+
);
|
|
125
|
+
send(response, 200, answer);
|
|
126
|
+
})();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ListenTarget {
|
|
131
|
+
/** A Unix socket path, when the caller would rather not use a port. */
|
|
132
|
+
socket?: string;
|
|
133
|
+
port?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function listen(server: Server, target: ListenTarget): Promise<string> {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
server.once('error', reject);
|
|
139
|
+
if (target.socket !== undefined) {
|
|
140
|
+
server.listen(target.socket, () => resolve(target.socket!));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// The host is not a parameter. See the module note.
|
|
144
|
+
server.listen(target.port ?? DEFAULT_PORT, BIND_HOST, () => {
|
|
145
|
+
const address = server.address();
|
|
146
|
+
resolve(typeof address === 'object' && address !== null ? `${BIND_HOST}:${address.port}` : String(address));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
package/src/watch-run.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
|
|
15
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { SAFE_FETCH_INIT } from '@trazum/core/node';
|
|
18
|
+
import type { WatchCrossing } from '@trazum/core';
|
|
19
|
+
|
|
20
|
+
export const WATCH_STATE_FILE = '.trazum/watch.json';
|
|
21
|
+
|
|
22
|
+
export const WATCH_STATE_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
export interface WatchState {
|
|
25
|
+
v: number;
|
|
26
|
+
/** When the last cycle ran, so a long silence can be told from a first run. */
|
|
27
|
+
lastCycleMs: number;
|
|
28
|
+
/** How far the measurements reached, for the coverage gap. */
|
|
29
|
+
lastCoveredToMs: number | null;
|
|
30
|
+
/** Gate keys already alerted on, so a restart is not amnesia. */
|
|
31
|
+
fired: Record<string, number>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readWatchState(root: string): Promise<WatchState | null> {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(await readFile(join(root, WATCH_STATE_FILE), 'utf8')) as WatchState;
|
|
37
|
+
if (parsed?.v !== WATCH_STATE_VERSION) return null;
|
|
38
|
+
return parsed;
|
|
39
|
+
} catch {
|
|
40
|
+
// No state, or state this version cannot read: a first cycle either way,
|
|
41
|
+
// which is a state the caller reports rather than an error.
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function writeWatchState(root: string, state: WatchState): Promise<void> {
|
|
47
|
+
const path = join(root, WATCH_STATE_FILE);
|
|
48
|
+
await mkdir(dirname(path), { recursive: true });
|
|
49
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a webhook URL is one this tool will post to.
|
|
54
|
+
*
|
|
55
|
+
* **This is not the SSRF case and the difference matters.** `checkedEndpoint`
|
|
56
|
+
* exists because a *request body* must never name a host: an anonymous caller
|
|
57
|
+
* pointing a shared server at an internal address is somebody else's machine
|
|
58
|
+
* reaching somewhere it was never meant to. Here the URL is in the operator's
|
|
59
|
+
* own config, on their own machine, and pointing it at their own alerting
|
|
60
|
+
* daemon on loopback is the ordinary case rather than the attack.
|
|
61
|
+
*
|
|
62
|
+
* So loopback is allowed and plain http is allowed *only* there, while two
|
|
63
|
+
* rules stay absolute: no credentials embedded in the URL, because a URL ends
|
|
64
|
+
* up in logs and shell history; and https everywhere else, because an alert
|
|
65
|
+
* carries spend figures across a network.
|
|
66
|
+
*/
|
|
67
|
+
export type WebhookRejection = 'invalid-url' | 'credentials-in-url' | 'insecure-scheme';
|
|
68
|
+
|
|
69
|
+
export function checkWebhook(raw: string): { ok: true; url: URL } | { ok: false; reason: WebhookRejection } {
|
|
70
|
+
let url: URL;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: 'invalid-url' };
|
|
75
|
+
}
|
|
76
|
+
if (url.username !== '' || url.password !== '') {
|
|
77
|
+
return { ok: false, reason: 'credentials-in-url' };
|
|
78
|
+
}
|
|
79
|
+
const loopback =
|
|
80
|
+
url.hostname === 'localhost' ||
|
|
81
|
+
url.hostname === '127.0.0.1' ||
|
|
82
|
+
url.hostname === '[::1]' ||
|
|
83
|
+
url.hostname === '::1';
|
|
84
|
+
if (url.protocol === 'https:') return { ok: true, url };
|
|
85
|
+
if (url.protocol === 'http:' && loopback) return { ok: true, url };
|
|
86
|
+
return { ok: false, reason: 'insecure-scheme' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The alert payload.
|
|
91
|
+
*
|
|
92
|
+
* Figures and gate names, never prompt text — the store has never held any and
|
|
93
|
+
* neither does this. Every crossing carries its own provenance, so a receiver
|
|
94
|
+
* that fans these into a dashboard cannot lose track of what kind of number it
|
|
95
|
+
* is holding.
|
|
96
|
+
*/
|
|
97
|
+
export interface WatchAlert {
|
|
98
|
+
schemaVersion: 1;
|
|
99
|
+
firedAtMs: number;
|
|
100
|
+
crossings: WatchCrossing[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function postWebhook(
|
|
104
|
+
url: URL,
|
|
105
|
+
alert: WatchAlert,
|
|
106
|
+
fetchImpl: typeof fetch = fetch,
|
|
107
|
+
): Promise<{ ok: boolean; status: number | null; error: string | null }> {
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetchImpl(url.toString(), {
|
|
110
|
+
...SAFE_FETCH_INIT,
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(alert),
|
|
114
|
+
signal: AbortSignal.timeout(10_000),
|
|
115
|
+
});
|
|
116
|
+
return { ok: response.ok, status: response.status, error: null };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
/**
|
|
119
|
+
* A webhook that will not deliver must not take the alert down with it.
|
|
120
|
+
* The exit code and the stdout event have already carried the crossing;
|
|
121
|
+
* losing those because a receiver is down would make the quietest failure
|
|
122
|
+
* the loudest one.
|
|
123
|
+
*/
|
|
124
|
+
return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
|
|
125
|
+
}
|
|
126
|
+
}
|