@trazum/cli 1.37.0 → 1.39.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/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  cacheableMinimum,
12
12
  analyzeCachePrefix,
13
13
  billLevers,
14
+ buildPlan,
15
+ verifyPlan,
14
16
  cacheEconomics,
15
17
  cacheHitRate,
16
18
  contextPressure,
@@ -78,6 +80,8 @@ import { dayOf, formatGap, median, spanDays } from './time.js';
78
80
  import type {
79
81
  FleetSource,
80
82
  MeasuredUsage,
83
+ PlanDocument,
84
+ VerifiedAction,
81
85
  BaselineBreach,
82
86
  BaselineChange,
83
87
  BaselineComparison,
@@ -172,6 +176,7 @@ interface Args {
172
176
  const VALUE_FLAGS = new Set([
173
177
  'against',
174
178
  'from-log',
179
+ 'min-usd',
175
180
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
176
181
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
177
182
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -312,6 +317,13 @@ function levelFlag(args: Args, config: TrazumConfig, t: CliMessages): RuleLevel
312
317
  * model id. It beats the default because reading the code is better than
313
318
  * assuming, and loses to config because being told is better than reading.
314
319
  */
320
+ /**
321
+ * The file names a usage log answers to, shared by every command that reads a
322
+ * directory of them. One list, because two commands disagreeing on what counts
323
+ * as a log would be the same directory billing differently by verb.
324
+ */
325
+ const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
326
+
315
327
  /**
316
328
  * One usage log, gzip included, shared by every command that reads one.
317
329
  *
@@ -465,6 +477,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
465
477
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
466
478
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
467
479
  profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
480
+ plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
481
+ verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
468
482
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
469
483
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
470
484
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2204,6 +2218,274 @@ function isoDate(): string {
2204
2218
  * metered API calls somebody was actually billed for — the bill exists wherever
2205
2219
  * Trazum happens to be running, so the host has no bearing on it.
2206
2220
  */
2221
+ /**
2222
+ * `trazum verify <plan.json> --against <newer.jsonl|dir>` — did it work?
2223
+ *
2224
+ * The plan predicted; this holds the prediction to the log that came after
2225
+ * it. Three outcomes and never two — arrived, did not arrive, cannot be told
2226
+ * — because "cannot be told" rendered as "arrived" is how every other tool
2227
+ * congratulates a team for a workload that merely vanished. With `--gate`,
2228
+ * a broken promise is a failing exit code: a different and more useful gate
2229
+ * than "spend went up".
2230
+ */
2231
+ async function commandVerify(
2232
+ args: Args,
2233
+ pricing: PricingCatalogue,
2234
+ t: CliMessages,
2235
+ ): Promise<void> {
2236
+ const planPath = args.positional[0];
2237
+ if (planPath === undefined) throw new Error(t.verify.noTarget());
2238
+ const againstPath = stringFlag(args, 'against');
2239
+ if (againstPath === undefined) throw new Error(t.verify.needsAgainst());
2240
+
2241
+ let plan: PlanDocument & { createdAt?: string };
2242
+ try {
2243
+ const parsed = JSON.parse(await readFile(planPath, 'utf8'));
2244
+ if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.actions)) {
2245
+ throw new Error(t.verify.badPlan(planPath));
2246
+ }
2247
+ plan = parsed;
2248
+ } catch (error) {
2249
+ if (error instanceof SyntaxError) throw new Error(t.verify.badPlan(planPath));
2250
+ throw error;
2251
+ }
2252
+
2253
+ const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
2254
+ const READABLE = [...LOG_EXTENSIONS, ...GZ];
2255
+ const target = await stat(againstPath).catch(() => null);
2256
+ let files: string[] = [againstPath];
2257
+ if (target?.isDirectory()) {
2258
+ const entries = await readdir(againstPath, { withFileTypes: true });
2259
+ files = entries
2260
+ .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2261
+ .map((entry) => join(againstPath, entry.name))
2262
+ .sort((a, b) => a.localeCompare(b));
2263
+ if (files.length === 0) throw new Error(t.profile.noLogsInDirectory(againstPath, READABLE.join(', ')));
2264
+ }
2265
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
2266
+ const raw = texts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2267
+ const report = profileUsage(raw, { catalogue: pricing });
2268
+
2269
+ const verification = verifyPlan(plan, report, { currentPricingLastReviewed: pricing.lastReviewed });
2270
+ const gate = boolFlag(args, 'gate');
2271
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2272
+
2273
+ const lines = (md: boolean): string[] => {
2274
+ const out: string[] = [];
2275
+ const actionLine = (v: VerifiedAction): string[] => {
2276
+ const name = v.action.label === UNLABELLED ? t.profile.unlabelled() : v.action.label;
2277
+ const rows: string[] = [];
2278
+ rows.push(t.verify.action(v.action.kind, name, v.action.model, v.outcome));
2279
+ if (v.outcome === 'cannot-tell' && v.reason !== null) rows.push(t.verify.reason(v.reason));
2280
+ if (v.action.kind === 'route' || v.action.kind === 'route+batch') {
2281
+ if (v.outcome !== 'cannot-tell') {
2282
+ rows.push(
2283
+ t.verify.routeObserved(
2284
+ String(v.observed.dearestModel ?? ''),
2285
+ formatUsd(Number(v.observed.onTargetUsd ?? 0)),
2286
+ formatUsd(Number(v.observed.onOldModelUsd ?? 0)),
2287
+ ),
2288
+ );
2289
+ }
2290
+ if (v.action.kind === 'route+batch' && v.outcome !== 'cannot-tell') rows.push(t.verify.batchUnobservable());
2291
+ }
2292
+ if (v.action.kind === 'fix-truncation' && v.outcome === 'not-arrived') {
2293
+ rows.push(t.verify.truncationObserved(formatUsd(Number(v.observed.retryBillUsd ?? 0))));
2294
+ }
2295
+ if (v.action.kind === 'fix-caching' && v.outcome !== 'cannot-tell') {
2296
+ rows.push(t.verify.cacheObserved(formatUsd(Number(v.observed.deltaUsd ?? 0)), v.outcome));
2297
+ }
2298
+ if (v.attribution?.calls !== undefined) {
2299
+ rows.push(
2300
+ t.verify.attribution(
2301
+ n(Math.round(v.attribution.calls.before)),
2302
+ n(Math.round(v.attribution.calls.after)),
2303
+ n(Math.round(v.attribution.outputPerCallTokens?.before ?? 0)),
2304
+ n(Math.round(v.attribution.outputPerCallTokens?.after ?? 0)),
2305
+ ),
2306
+ );
2307
+ }
2308
+ return rows;
2309
+ };
2310
+
2311
+ const heading = t.verify.heading(
2312
+ n(verification.actions.length),
2313
+ verification.planCreatedAt === null ? null : verification.planCreatedAt.slice(0, 10),
2314
+ );
2315
+ out.push(md ? `## ${heading}` : heading);
2316
+ out.push(
2317
+ t.verify.counts(n(verification.arrived), n(verification.notArrived), n(verification.cannotTell)),
2318
+ );
2319
+ if (verification.pricesChanged) {
2320
+ out.push(t.verify.pricesChanged(verification.planPricing, verification.currentPricing));
2321
+ }
2322
+ for (const v of verification.actions) {
2323
+ out.push('');
2324
+ const [head, ...rest] = actionLine(v);
2325
+ out.push(md ? `### ${head}` : `→ ${head}`);
2326
+ for (const row of rest) out.push(md ? `- ${row}` : ` · ${row}`);
2327
+ }
2328
+ out.push('');
2329
+ out.push(t.verify.footer());
2330
+ return out;
2331
+ };
2332
+
2333
+ await writeMarkdown(args, () => lines(true).join('\n'));
2334
+
2335
+ if (boolFlag(args, 'json')) {
2336
+ console.log(JSON.stringify(verification, null, 2));
2337
+ } else {
2338
+ const [head, ...rest] = lines(false);
2339
+ console.log(c.bold(head!));
2340
+ for (const row of rest) {
2341
+ console.log(row === '' ? '' : ` ${wrap(row, 74, ' ')}`);
2342
+ }
2343
+ }
2344
+
2345
+ if (gate) {
2346
+ if (verification.gateFailures > 0) {
2347
+ console.error(c.red(t.verify.gateFailed(n(verification.gateFailures), n(verification.actions.length))));
2348
+ process.exitCode = 1;
2349
+ } else {
2350
+ console.log(c.green(t.verify.gateOk()));
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ /**
2356
+ * `trazum plan <log>` — not a list of findings, a ranked plan of what to do.
2357
+ *
2358
+ * The composition (route and batch on one slice never summed) happens in
2359
+ * core's `buildPlan`; this command owns the I/O and the rendering. The plan
2360
+ * saves as a dated JSON file on request, which is what makes verifying it
2361
+ * against a later log possible at all — a prediction nobody wrote down is a
2362
+ * prediction nobody can be held to.
2363
+ */
2364
+ async function commandPlan(
2365
+ args: Args,
2366
+ pricing: PricingCatalogue,
2367
+ t: CliMessages,
2368
+ ): Promise<void> {
2369
+ const path = args.positional[0];
2370
+ if (path === undefined) throw new Error(t.plan.noTarget());
2371
+
2372
+ const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
2373
+ const READABLE = [...LOG_EXTENSIONS, ...GZ];
2374
+ const target = await stat(path).catch(() => null);
2375
+ let files: string[] = [path];
2376
+ if (target?.isDirectory()) {
2377
+ const entries = await readdir(path, { withFileTypes: true });
2378
+ files = entries
2379
+ .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2380
+ .map((entry) => join(path, entry.name))
2381
+ .sort((a, b) => a.localeCompare(b));
2382
+ if (files.length === 0) throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
2383
+ }
2384
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
2385
+ const raw = texts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2386
+
2387
+ const report = profileUsage(raw, { catalogue: pricing });
2388
+ if (report.total.calls === 0) throw new Error(t.plan.nothingPriced());
2389
+ const levers = billLevers(report, { catalogue: pricing });
2390
+ const plan = buildPlan(report, levers, pricing.lastReviewed);
2391
+
2392
+ const minUsd = typeof args.flags.get('min-usd') === 'string' ? numberFlag(args, 'min-usd', 0, t) : 0;
2393
+ const actions = plan.actions.filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) >= minUsd);
2394
+ const filtered = plan.actions.length - actions.length;
2395
+ const droppedUsd = plan.actions
2396
+ .filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) < minUsd)
2397
+ .reduce((sum, a) => sum + (a.savingUsd ?? a.stakeUsd ?? 0), 0);
2398
+
2399
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2400
+ /**
2401
+ * The document's totals cover the actions the document holds — a filtered
2402
+ * plan whose totals still counted the filtered actions would be a file
2403
+ * that contradicts itself, and 1.39's verify would hold it to money it
2404
+ * cannot see. What --min-usd dropped is stated with its worth, never
2405
+ * silently.
2406
+ */
2407
+ const stamped = {
2408
+ ...plan,
2409
+ actions,
2410
+ projectedSavingUsd: actions.reduce((sum, a) => sum + (a.savingUsd ?? 0), 0),
2411
+ measuredStakeUsd: actions.reduce((sum, a) => sum + (a.stakeUsd ?? 0), 0),
2412
+ createdAt: new Date().toISOString(),
2413
+ };
2414
+
2415
+ const outPath = stringFlag(args, 'out');
2416
+ if (outPath !== undefined) {
2417
+ await writeFile(outPath, `${JSON.stringify(stamped, null, 2)}\n`);
2418
+ }
2419
+
2420
+ await writeMarkdown(args, () => {
2421
+ const lines: string[] = [];
2422
+ lines.push(`## ${t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))}`);
2423
+ lines.push('');
2424
+ lines.push(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)));
2425
+ if (plan.span === null) {
2426
+ lines.push('');
2427
+ lines.push(`_${t.plan.noClock()}_`);
2428
+ }
2429
+ for (const action of actions) {
2430
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
2431
+ const money =
2432
+ action.savingUsd !== null
2433
+ ? t.plan.projected(formatUsd(action.savingUsd))
2434
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
2435
+ lines.push('');
2436
+ lines.push(`### ${t.plan.action(action.kind, name, action.model)} — ${money}`);
2437
+ if (action.detail.routeTo !== undefined) lines.push(`- ${t.plan.routeTo(action.detail.routeTo.displayName)}`);
2438
+ for (const assumption of action.assumes) lines.push(`- ${t.plan.assume(assumption)}`);
2439
+ if (action.check !== null) lines.push(`- ${t.plan.check(action.check)}`);
2440
+ }
2441
+ if (filtered > 0) {
2442
+ lines.push('');
2443
+ lines.push(`_${t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd))}_`);
2444
+ }
2445
+ lines.push('');
2446
+ lines.push(`_${t.plan.footer()}_`);
2447
+ return lines.join('\n');
2448
+ });
2449
+
2450
+ if (boolFlag(args, 'json')) {
2451
+ console.log(JSON.stringify(stamped, null, 2));
2452
+ return;
2453
+ }
2454
+
2455
+ console.log(c.bold(t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))));
2456
+ console.log(
2457
+ ` ${wrap(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)), 74, ' ')}`,
2458
+ );
2459
+ if (plan.span === null) {
2460
+ console.log(` ${c.dim(wrap(t.plan.noClock(), 74, ' '))}`);
2461
+ }
2462
+ for (const action of actions) {
2463
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
2464
+ const money =
2465
+ action.savingUsd !== null
2466
+ ? t.plan.projected(formatUsd(action.savingUsd))
2467
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
2468
+ console.log();
2469
+ console.log(` ${c.green('→')} ${c.bold(t.plan.action(action.kind, name, action.model))} ${money}`);
2470
+ if (action.detail.routeTo !== undefined) {
2471
+ console.log(` ${c.dim(t.plan.routeTo(action.detail.routeTo.displayName))}`);
2472
+ }
2473
+ for (const assumption of action.assumes) {
2474
+ console.log(` ${c.yellow('?')} ${c.dim(wrap(t.plan.assume(assumption), 72, ' '))}`);
2475
+ }
2476
+ if (action.check !== null) {
2477
+ console.log(` ${c.dim(wrap(t.plan.check(action.check), 72, ' '))}`);
2478
+ }
2479
+ }
2480
+ if (filtered > 0) {
2481
+ console.log();
2482
+ console.log(` ${c.dim(wrap(t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd)), 74, ' '))}`);
2483
+ }
2484
+ console.log();
2485
+ console.log(` ${c.dim(wrap(t.plan.footer(), 74, ' '))}`);
2486
+ if (outPath !== undefined) console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
2487
+ }
2488
+
2207
2489
  async function commandProfile(args: Args, config: TrazumConfig, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
2208
2490
  const path = args.positional[0];
2209
2491
  if (path === undefined) {
@@ -2227,7 +2509,6 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2227
2509
  * directory holding nothing readable is an error naming what it looked for,
2228
2510
  * not an empty report.
2229
2511
  */
2230
- const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
2231
2512
  /**
2232
2513
  * The same names, gzipped — which is what a rotated log actually looks like
2233
2514
  * a day after it rotates.
@@ -6015,6 +6296,12 @@ async function main(): Promise<void> {
6015
6296
  case 'profile':
6016
6297
  await commandProfile(args, config, pricing, t);
6017
6298
  break;
6299
+ case 'plan':
6300
+ await commandPlan(args, pricing, t);
6301
+ break;
6302
+ case 'verify':
6303
+ await commandVerify(args, pricing, t);
6304
+ break;
6018
6305
  case 'route':
6019
6306
  await commandRoute(args, pricing, t);
6020
6307
  break;