@trazum/mcp 1.9.0 → 1.25.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/dist/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BUNDLED_CATALOGUE, PRICING_LAST_REVIEWED, formatUsd, listModels, optimize, } from '@trazum/core';
1
+ import { BUNDLED_CATALOGUE, PRICING_LAST_REVIEWED, UNLABELLED, billLevers, cacheEconomics, cacheHitRate, driversBetween, formatUsd, reviewAgeDays, listModels, optimize, profileUsage, repriceProfile, } from '@trazum/core';
2
2
  import { InvalidArguments } from './rpc.js';
3
3
  /**
4
4
  * The tools, kept in one file so the whole surface an agent can reach reads in one
@@ -29,7 +29,7 @@ import { InvalidArguments } from './rpc.js';
29
29
  */
30
30
  export const MAX_PROMPT_CHARS = 400_000;
31
31
  /** Every figure this server prints descends from the estimator, so it says so. */
32
- const BAND_NOTE = 'token counts are estimates (±15% on prose, calibrated on Claude); prices reviewed '
32
+ const BAND_NOTE = 'token counts are estimates (±10% on prose, calibrated on Claude); prices reviewed '
33
33
  + PRICING_LAST_REVIEWED;
34
34
  function promptFrom(args) {
35
35
  const prompt = args.prompt;
@@ -182,7 +182,7 @@ const CHECK = {
182
182
  + ` is ${result.tokensAfter}: content has to be cut.`;
183
183
  return [
184
184
  verdict,
185
- 'token counts are estimates (±15% on prose, calibrated on Claude), so a prompt within'
185
+ 'token counts are estimates (±10% on prose, calibrated on Claude), so a prompt within'
186
186
  + ' a few percent of its budget should be treated as uncertain',
187
187
  ].join('\n');
188
188
  },
@@ -208,6 +208,618 @@ const MODELS = {
208
208
  ].join('\n');
209
209
  },
210
210
  };
211
+ /**
212
+ * Larger than the prompt cap because a usage log is a different object: a month
213
+ * of calls at one JSON line each. Two million characters is a few MB of log —
214
+ * far beyond what an agent realistically holds in context, so the cap exists to
215
+ * refuse the accident, not to invite the maximum.
216
+ */
217
+ export const MAX_LOG_CHARS = 2_000_000;
218
+ /** `<1%` for a real but sub-half-percent share, never a rounded-to-zero "0%". */
219
+ const pct = (fraction) => fraction > 0 && fraction < 0.005 ? '<1%' : `${(fraction * 100).toFixed(0)}%`;
220
+ const count = (n, word) => `${n.toLocaleString('en-US')} ${n === 1 ? word : `${word}s`}`;
221
+ const PROFILE = {
222
+ name: 'profile_usage',
223
+ title: 'Where the money went, from a usage log',
224
+ description: 'Reads a usage log — one JSON object per line, each with a "model" and the "usage" object '
225
+ + 'the API returned — and says where the money went: the spend split, per label and per '
226
+ + 'model, whether caching paid for itself, and which levers would actually move the bill. '
227
+ + 'These are the provider\'s own billed token counts, not estimates. Pass the log text '
228
+ + 'itself: this server never reads files. Add "label", "session" and "ts" fields to the '
229
+ + 'records to unlock the per-workload findings, conversation growth, the period the log '
230
+ + 'covers, and whether the cache TTL fits how fast the turns arrive; the session key is '
231
+ + 'grouped by and never shown.',
232
+ inputSchema: {
233
+ type: 'object',
234
+ properties: {
235
+ log: {
236
+ type: 'string',
237
+ minLength: 1,
238
+ maxLength: MAX_LOG_CHARS,
239
+ description: 'The usage log text, one JSON object per line. Never a file path.',
240
+ },
241
+ label: {
242
+ type: 'string',
243
+ minLength: 1,
244
+ description: 'Profile only the calls carrying this label — the drill-down once the full report '
245
+ + 'named a suspect. A label matching nothing is an error naming the labels that exist.',
246
+ },
247
+ since: {
248
+ type: 'string',
249
+ minLength: 1,
250
+ description: 'Profile only calls at or after this moment: a UTC day (2026-08-14) or a full ISO '
251
+ + '8601 timestamp. Calls with no "ts" cannot be placed and are excluded, counted out '
252
+ + 'loud — never dropped silently.',
253
+ },
254
+ until: {
255
+ type: 'string',
256
+ minLength: 1,
257
+ description: 'Profile only calls up to this moment; a bare date includes that whole UTC day. '
258
+ + 'A window matching nothing is an error naming what the log does cover.',
259
+ },
260
+ previous_log: {
261
+ type: 'string',
262
+ minLength: 1,
263
+ maxLength: MAX_LOG_CHARS,
264
+ description: 'A previous usage log to compare against, as text — never a file path. Positive '
265
+ + 'means the bill grew. Drivers of the change are named per label and per model, '
266
+ + 'appeared and vanished workloads included; label/since/until filter both logs, '
267
+ + 'so the comparison stays one workload and one period.',
268
+ },
269
+ what_if: {
270
+ type: 'string',
271
+ minLength: 1,
272
+ description: 'Price these exact calls on another model id. The same token counts at a different '
273
+ + 'rate card — multiplication, not advice: it says nothing about whether that model '
274
+ + 'could do the work. Calls larger than that model\'s context window are named as '
275
+ + 'impossible rather than priced as cheap, and spend already on that model stays out '
276
+ + 'of the difference. An id this catalogue cannot price is an error, never silence.',
277
+ },
278
+ },
279
+ required: ['log'],
280
+ additionalProperties: false,
281
+ },
282
+ run: (args) => {
283
+ const log = args.log;
284
+ if (typeof log !== 'string')
285
+ throw new InvalidArguments('log must be a string');
286
+ if (log.length === 0)
287
+ throw new InvalidArguments('log is empty');
288
+ if (log.length > MAX_LOG_CHARS) {
289
+ throw new InvalidArguments(`log is ${log.length} characters, over the ${MAX_LOG_CHARS} limit`);
290
+ }
291
+ const onlyLabel = args.label;
292
+ if (onlyLabel !== undefined && typeof onlyLabel !== 'string') {
293
+ throw new InvalidArguments('label must be a string');
294
+ }
295
+ /**
296
+ * The window, under the CLI's rules: a bare day is that whole UTC day —
297
+ * since its first instant, until its last — and the window is half-open
298
+ * `[since, until)` internally, so adjacent windows share no record.
299
+ */
300
+ const parseWhen = (key) => {
301
+ const value = args[key];
302
+ if (value === undefined)
303
+ return undefined;
304
+ if (typeof value !== 'string')
305
+ throw new InvalidArguments(`${key} must be a string`);
306
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
307
+ const midnight = Date.parse(`${value}T00:00:00Z`);
308
+ if (Number.isFinite(midnight))
309
+ return key === 'until' ? midnight + 86_400_000 : midnight;
310
+ }
311
+ const exact = Date.parse(value);
312
+ if (Number.isFinite(exact))
313
+ return exact;
314
+ throw new InvalidArguments(`${key} could not be read: "${value}". Pass a UTC day (2026-08-14) or a full ISO 8601 timestamp.`);
315
+ };
316
+ const sinceMs = parseWhen('since');
317
+ const untilMs = parseWhen('until');
318
+ if (sinceMs !== undefined && untilMs !== undefined && sinceMs >= untilMs) {
319
+ throw new InvalidArguments('since is at or after until, so the window contains no time at all');
320
+ }
321
+ const windowed = sinceMs !== undefined || untilMs !== undefined;
322
+ const report = profileUsage(log, { catalogue: BUNDLED_CATALOGUE, label: onlyLabel, sinceMs, untilMs });
323
+ /**
324
+ * The drill-downs' one rule, same as the CLI: a filter matching nothing is
325
+ * an error naming what exists, never a silent report over zero calls that
326
+ * an agent would read as "this workload is free" or "this period is free".
327
+ */
328
+ if ((onlyLabel !== undefined || windowed) && report.total.calls === 0 && report.unpriced.calls === 0) {
329
+ const unfiltered = profileUsage(log, { catalogue: BUNDLED_CATALOGUE });
330
+ if (unfiltered.total.calls > 0 || unfiltered.unpriced.calls > 0) {
331
+ if (onlyLabel !== undefined && !unfiltered.byLabel.some((e) => e.label === onlyLabel)) {
332
+ const available = unfiltered.byLabel
333
+ .map((e) => (e.label === UNLABELLED ? '(no label)' : e.label))
334
+ .join(', ');
335
+ throw new InvalidArguments(`no call in this log carries the label "${onlyLabel}". The labels here are: ${available || '—'}`);
336
+ }
337
+ if (windowed) {
338
+ if (unfiltered.span === null) {
339
+ throw new InvalidArguments('no record in this log carries a timestamp, so since/until have nothing to filter by. '
340
+ + 'Add "ts" to the records.');
341
+ }
342
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
343
+ throw new InvalidArguments(`no record falls inside this window. The log covers ${day(unfiltered.span.fromMs)} → ${day(unfiltered.span.toMs)}.`);
344
+ }
345
+ }
346
+ }
347
+ const { total } = report;
348
+ const lines = [];
349
+ const gaps = () => {
350
+ if (report.unpricedModels.length > 0) {
351
+ lines.push(`${count(report.unpriced.calls, 'call')} are not in these totals — the pricing `
352
+ + `catalogue does not know: ${report.unpricedModels.join(', ')}. The CLI can price `
353
+ + 'them with a pricing overlay (trazum profile --pricing).');
354
+ }
355
+ if (report.skippedLines.length > 0) {
356
+ const shown = report.skippedLines.slice(0, 10).join(', ');
357
+ const more = report.skippedLines.length > 10 ? ', …' : '';
358
+ lines.push(`${count(report.skippedLines.length, 'line')} could not be read and`
359
+ + ` ${report.skippedLines.length === 1 ? 'was' : 'were'} left out`
360
+ + ` (line${report.skippedLines.length === 1 ? '' : 's'} ${shown}${more}).`);
361
+ }
362
+ };
363
+ if (total.calls === 0) {
364
+ lines.push(report.unpriced.calls > 0
365
+ ? 'None of the models in that log are in the pricing catalogue, so there is no bill '
366
+ + 'to report.'
367
+ : 'No usage records in that log.');
368
+ gaps();
369
+ return lines.join('\n');
370
+ }
371
+ // The one Trazum surface whose figures are NOT estimates, said out loud
372
+ // because every sibling tool here carries the ±10% band.
373
+ lines.push(`${count(total.calls, 'call')} · ${formatUsd(total.totalUsd)} — exact billed token`
374
+ + ` counts from the log, not estimates; prices reviewed ${PRICING_LAST_REVIEWED}.`);
375
+ /**
376
+ * Said only when old enough to matter, and loud then: a stale table
377
+ * qualifies every dollar above, and unlike a skipped line it does not
378
+ * name its own size — the error is exactly whatever the provider changed.
379
+ */
380
+ const pricingAge = reviewAgeDays(PRICING_LAST_REVIEWED, new Date());
381
+ if (pricingAge !== null && pricingAge > 45) {
382
+ lines.push(`That review was ${pricingAge} days ago, past the 45 this tool considers current. If the`
383
+ + ' provider changed prices since, every figure here is off by exactly that change —'
384
+ + ' the CLI can fetch current prices (trazum profile --pricing-live).');
385
+ }
386
+ if (report.span !== null) {
387
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
388
+ const days = ((report.span.toMs - report.span.fromMs) / 86_400_000).toFixed(1);
389
+ const parsed = total.calls + report.unpriced.calls;
390
+ const partial = report.span.calls < parsed
391
+ ? ` Only ${count(report.span.calls, 'call')} of ${parsed} carry a timestamp; the span describes those.`
392
+ : '';
393
+ lines.push(`This log covers ${day(report.span.fromMs)} → ${day(report.span.toMs)} (${days} days).`
394
+ + ' The span is stated, never extrapolated — the monthly arithmetic is yours to do.'
395
+ + partial);
396
+ }
397
+ else {
398
+ lines.push('Figures are "on this bill" — the log carries no timestamps, so no period is known'
399
+ + ' and nothing here is per-month. Add "ts" to the record and the span is stated.');
400
+ }
401
+ // The window before any figure is trusted as "the log", with the undated
402
+ // count said out loud: those calls' spend is in the log and not here.
403
+ if (report.timeWindow !== null) {
404
+ lines.push('Everything below describes the since/until window, not the whole log.');
405
+ if (report.timeWindow.undatedExcluded > 0) {
406
+ lines.push(`${count(report.timeWindow.undatedExcluded, 'call')} carry no timestamp and cannot be`
407
+ + ' placed inside or outside the window, so they were left out. The window\'s figures'
408
+ + ' are a floor on the period.');
409
+ }
410
+ }
411
+ lines.push(`input ${formatUsd(total.inputUsd)} · cache reads ${formatUsd(total.cacheReadUsd)}`
412
+ + ` · cache writes ${formatUsd(total.cacheWriteUsd)}`
413
+ + ` · output ${formatUsd(total.outputUsd)} (${pct(total.outputUsd / total.totalUsd)})`);
414
+ const name = (label) => (label === UNLABELLED ? '(no label)' : label);
415
+ const table = (heading, rows) => {
416
+ lines.push('', `--- ${heading} ---`);
417
+ for (const row of rows.slice(0, 10)) {
418
+ lines.push(`${formatUsd(row.usd).padStart(11)} ${pct(row.usd / total.totalUsd).padStart(4)}`
419
+ + ` ${row.key} (${count(row.calls, 'call')})`);
420
+ }
421
+ if (rows.length > 10)
422
+ lines.push(`…and ${rows.length - 10} more.`);
423
+ };
424
+ table('by label', report.byLabel.map((e) => ({
425
+ key: name(e.label), usd: e.breakdown.totalUsd, calls: e.breakdown.calls,
426
+ })));
427
+ table('by model', report.byModel.map((e) => ({
428
+ key: e.model, usd: e.breakdown.totalUsd, calls: e.breakdown.calls,
429
+ })));
430
+ lines.push('', '--- did caching pay for itself? ---');
431
+ const cache = cacheEconomics(total);
432
+ /**
433
+ * The verdict is unsettled when the TTL assumption alone flips it: neither
434
+ * end may be stated as the answer, and the flattering half least of all.
435
+ * Same gate as the CLI and the web viewer.
436
+ */
437
+ const unsettled = cache.worstCaseVerdict !== cache.verdict && total.assumedWriteTtlCalls > 0;
438
+ if (unsettled) {
439
+ lines.push(`This log cannot say whether caching paid for itself. ${count(total.assumedWriteTtlCalls, 'call')}`
440
+ + ' did not record which cache-write TTL was used: at the 5-minute rate caching took'
441
+ + ` ${formatUsd(Math.abs(cache.deltaUsd))} off this bill, and at the 1-hour rate the`
442
+ + ` same calls added ${formatUsd(Math.abs(cache.worstCaseDeltaUsd))} to it. Neither is`
443
+ + ' the answer. Record the "cache_creation" object the API returns and this settles itself.');
444
+ }
445
+ else if (cache.verdict === 'not-attempted') {
446
+ lines.push('Caching was never used on these calls. If any prefix repeats, that is the largest '
447
+ + 'saving available.');
448
+ }
449
+ else if (cache.verdict === 'unpriced') {
450
+ lines.push('Cache tokens exist on models the catalogue cannot price; no comparison to make.');
451
+ }
452
+ else if (cache.verdict === 'lost-money') {
453
+ lines.push(`Caching added ${formatUsd(cache.deltaUsd)} to this bill instead of taking it off — a`
454
+ + ' write costs 1.25x plain input (2x at the 1-hour TTL), so a prefix that changes'
455
+ + ' faster than it is reused pays that premium for nothing.');
456
+ }
457
+ else if (cache.verdict === 'paid-off') {
458
+ lines.push(`Caching took ${formatUsd(Math.abs(cache.deltaUsd))} off this bill, against the same`
459
+ + ' tokens uncached.');
460
+ }
461
+ else {
462
+ lines.push('Caching came out level: it charged what the same tokens cost as plain input.');
463
+ }
464
+ if (!unsettled && total.assumedWriteTtlCalls > 0) {
465
+ lines.push(`That figure is a bound, not a measurement: ${count(total.assumedWriteTtlCalls, 'call')}`
466
+ + ' did not record a cache-write TTL.');
467
+ }
468
+ const losing = report.byLabel.filter((e) => cacheEconomics(e.breakdown).verdict === 'lost-money');
469
+ if (cache.verdict !== 'lost-money' && losing.length > 0) {
470
+ lines.push(`The total hides a loss: caching loses money on ${losing.map((e) => name(e.label)).join(', ')}.`);
471
+ }
472
+ const hit = cacheHitRate(total);
473
+ if (hit !== null)
474
+ lines.push(`Cache hit rate ${pct(hit)} of billable input.`);
475
+ /**
476
+ * Whether the TTL fits how fast the turns arrive — the mechanism behind the
477
+ * verdict above. Four verdicts plus "could not be measured": the same
478
+ * three-state discipline as truncation, because for an agent acting on this
479
+ * report "no data" and "fits" are different instructions.
480
+ */
481
+ const gapOf = (ms) => {
482
+ if (ms < 90_000)
483
+ return `${Math.round(ms / 1000)}s`;
484
+ if (ms < 90 * 60_000)
485
+ return `${Math.round(ms / 60_000)}m`;
486
+ return `${(ms / 3_600_000).toFixed(1)}h`;
487
+ };
488
+ for (const fit of report.cacheTtlFit.slice(0, 3)) {
489
+ const who = `${name(fit.label)} on ${fit.modelName}`;
490
+ const gap = gapOf(fit.medianGapMs);
491
+ if (fit.verdict === 'expires-before-reuse') {
492
+ lines.push(`${who}: turns arrive a median of ${gap} apart and the cache entry is gone by then —`
493
+ + ' writes expire before the next turn reads them. Use the 1-hour TTL if it survives'
494
+ + ' these gaps, or turn caching off here.');
495
+ }
496
+ else if (fit.verdict === 'overlong-ttl') {
497
+ lines.push(`${who}: turns arrive a median of ${gap} apart — inside the 5-minute window — and these`
498
+ + ` writes pay the 1-hour rate (2x input) for endurance they never use. The same writes`
499
+ + ` at the 5-minute TTL are ${formatUsd(fit.overpayUsd)} cheaper on this log, exactly.`);
500
+ }
501
+ else if (fit.verdict === 'unsettled') {
502
+ lines.push(`${who}: median gap ${gap} — a 5-minute entry is gone by then, a 1-hour one survives,`
503
+ + ' and the log did not record which these writes were. Record the "cache_creation"'
504
+ + ' object and this settles itself.');
505
+ }
506
+ else {
507
+ lines.push(`${who}: median gap ${gap}, inside the lifetime these writes use. The TTL fits.`);
508
+ }
509
+ }
510
+ if (total.cacheWriteTokens > 0 && report.cacheTtlFit.length === 0) {
511
+ lines.push('Whether the cache TTL fits how fast the turns arrive could not be measured — it needs'
512
+ + ' both "session" and "ts" on the record.');
513
+ }
514
+ /**
515
+ * Conversations that never came back. The same two-claim split as the CLI:
516
+ * a fact when the slice recorded zero cache reads (nothing read those
517
+ * writes at all), a ceiling named as one otherwise — the provider's cache
518
+ * is keyed by prefix, and the log cannot see whose write a read hit.
519
+ */
520
+ const readsBySlice = new Map(report.byLabelAndModel.map((e) => [`${e.label}\n${e.model}`, e.breakdown.cacheReadTokens]));
521
+ for (const row of report.singleTurnCacheWrites.slice(0, 3)) {
522
+ const who = `${name(row.label)} on ${row.modelName}`;
523
+ const opening = `${who}: ${count(row.singleTurnSessions, 'conversation')} of ${row.sessions} ended after`
524
+ + ` the first turn and spent ${formatUsd(row.singleTurnWriteUsd)} on cache writes their`
525
+ + ' own conversation never read back.';
526
+ if ((readsBySlice.get(`${row.label}\n${row.model}`) ?? 0) === 0) {
527
+ lines.push(`${opening} Nothing in this log ever read this slice's cache at all, so those writes`
528
+ + ' bought nothing — stop marking one-shot calls with cache_control.');
529
+ }
530
+ else {
531
+ lines.push(`${opening} Another conversation sharing the same prefix within the TTL could have read`
532
+ + ' them; the log cannot see whose write a read hit, so that figure is a ceiling on'
533
+ + ' the waste, not a bill.');
534
+ }
535
+ }
536
+ lines.push('', '--- what would actually move this bill ---');
537
+ const levers = billLevers(report, { catalogue: BUNDLED_CATALOGUE });
538
+ if (report.byLabel.length === 1 && report.byLabel[0].label === UNLABELLED) {
539
+ lines.push('None of these calls carried a label, so this is every workload in one row. Add "label" '
540
+ + 'to the record and the levers split by workload, the grouping a decision is made at.');
541
+ }
542
+ if (levers.slices.length === 0) {
543
+ lines.push('Nothing here clears 1% of the bill: these calls are already on the cheapest model of '
544
+ + 'their family, or their provider has no batch API. A real answer, not an empty section.');
545
+ }
546
+ for (const slice of levers.slices.slice(0, 5)) {
547
+ lines.push(`${name(slice.label)} on ${slice.modelName} — up to ${formatUsd(slice.combinedUsd)}`
548
+ + ` (${pct(slice.shareOfBill)}); ${count(slice.calls, 'call')}, ${formatUsd(slice.spentUsd)} spent`);
549
+ if (slice.route !== null) {
550
+ lines.push(` route to ${slice.route.candidate.displayName}: ${formatUsd(slice.route.savingUsd)}`
551
+ + ' — an evaluation question, not arithmetic; the CLI measures it: trazum route');
552
+ }
553
+ if (slice.batch !== null) {
554
+ lines.push(` batch API: ${formatUsd(slice.batch.savingUsd)}`);
555
+ }
556
+ }
557
+ lines.push(`For comparison, shortening prompt text can touch ${formatUsd(levers.promptCeilingUsd)}`
558
+ + ` at the very most (${pct(levers.promptCeilingShare)}) — a ceiling, and the real figure`
559
+ + ' is far below it: most input tokens are context, history and tool results no prompt'
560
+ + ' file contains.');
561
+ lines.push('', '--- conversations ---');
562
+ if (!report.hasSessions) {
563
+ lines.push('No call carried a session, so re-sending-the-conversation costs could not be measured '
564
+ + '— usually the largest line on a chat or agent bill. Add "session" to the record; '
565
+ + 'it is grouped by and never shown.');
566
+ }
567
+ for (const growth of report.conversations.slice(0, 3)) {
568
+ lines.push(`${name(growth.label)} on ${growth.modelName}: at most ${formatUsd(growth.growthUsd)}`
569
+ + ` of this bill is conversation growth (${pct(growth.shareOfBill)}) — a ceiling, not a`
570
+ + " saving; part is the user's own new messages. Input runs"
571
+ + ` ${Math.round(growth.minTurnTokens).toLocaleString('en-US')} to`
572
+ + ` ${Math.round(growth.maxTurnTokens).toLocaleString('en-US')} tokens per turn over`
573
+ + ` conversations up to ${growth.longestSession} turns.`);
574
+ }
575
+ /**
576
+ * What one conversation costs — median against p95, the figure a per-seat
577
+ * price or a quota is set from. A mean is refused for the reason it is
578
+ * refused everywhere: one runaway loop hides the ordinary case.
579
+ */
580
+ for (const shape of report.sessionCosts.slice(0, 3)) {
581
+ lines.push(`${name(shape.label)} on ${shape.modelName}: across ${shape.sessions} conversations the`
582
+ + ` median costs ${formatUsd(shape.medianUsd)} over ${shape.medianTurns} turns, 95% come`
583
+ + ` in under ${formatUsd(shape.p95Usd)}, the dearest was ${formatUsd(shape.maxUsd)}.`
584
+ + ' Exact billed counts per conversation; one that started before this log or continues'
585
+ + ' after it counts only for the turns recorded here.');
586
+ if (shape.medianUsd > 0 && shape.p95Usd > 10 * shape.medianUsd) {
587
+ lines.push(`That p95 is ${(shape.p95Usd / shape.medianUsd).toFixed(0)}x the median — a tail a quota`
588
+ + ' can catch, rather than a workload that is uniformly expensive.');
589
+ }
590
+ }
591
+ lines.push('', '--- truncation ---');
592
+ if (total.stopReasonCalls === 0) {
593
+ lines.push('Whether any answers were cut off could not be measured — no call carries a stop '
594
+ + 'reason. Add "stop_reason" (Anthropic) or "finish_reason" (OpenAI) to the record.');
595
+ }
596
+ else if (total.truncatedCalls > 0) {
597
+ lines.push(`${count(total.truncatedCalls, 'call')} hit the max_tokens ceiling:`
598
+ + ` ${formatUsd(total.truncatedOutputUsd)} of output`
599
+ + ` (${pct(total.outputUsd > 0 ? total.truncatedOutputUsd / total.outputUsd : 0)})`
600
+ + ' bought answers cut off mid-generation — paid in full and frequently retried.');
601
+ /**
602
+ * Which workloads pay for it, at a rate over calls that **recorded a
603
+ * stop reason** — never over every call, because a workload logging
604
+ * the field half the time is not one whose other half completed.
605
+ */
606
+ const truncating = report.byLabel
607
+ .filter((entry) => entry.breakdown.truncatedCalls > 0)
608
+ .sort((a, b) => b.breakdown.truncatedOutputUsd - a.breakdown.truncatedOutputUsd);
609
+ if (truncating.length > 0 && report.byLabel.length > 1) {
610
+ for (const entry of truncating.slice(0, 3)) {
611
+ lines.push(`${name(entry.label)}: ${entry.breakdown.truncatedCalls} of`
612
+ + ` ${entry.breakdown.stopReasonCalls} calls that recorded a stop reason were cut off`
613
+ + ` (${pct(entry.breakdown.truncatedCalls / entry.breakdown.stopReasonCalls)}),`
614
+ + ` ${formatUsd(entry.breakdown.truncatedOutputUsd)} of output. The denominator is`
615
+ + ' the calls that measured, not every call.');
616
+ }
617
+ }
618
+ const ceiling = report.outputShapes.find((shape) => shape.p95WithinTokens !== null);
619
+ if (ceiling !== undefined) {
620
+ lines.push(`95% of the answers that finished fit within ${ceiling.p95WithinTokens} output tokens —`
621
+ + ' the number a max_tokens cap wants. Measured on these calls, promised for nothing.');
622
+ }
623
+ }
624
+ else {
625
+ lines.push('Stop reasons were recorded, and no answer hit the max_tokens ceiling.');
626
+ }
627
+ /**
628
+ * This bill against the previous one. The same section the CLI prints,
629
+ * over the same shared `driversBetween` — appeared and vanished workloads
630
+ * named, the model split only when more than one model is involved, and a
631
+ * previous log with nothing priced reported as its own answer rather than
632
+ * as zero growth.
633
+ */
634
+ const previousLog = args.previous_log;
635
+ if (previousLog !== undefined) {
636
+ if (typeof previousLog !== 'string')
637
+ throw new InvalidArguments('previous_log must be a string');
638
+ if (previousLog.length > MAX_LOG_CHARS) {
639
+ throw new InvalidArguments(`previous_log is ${previousLog.length} characters, over the ${MAX_LOG_CHARS} limit`);
640
+ }
641
+ // The same filters on both sides: a windowed or drilled-down bill
642
+ // against the whole previous log would call every sibling workload a
643
+ // vanished saving.
644
+ const previous = profileUsage(previousLog, {
645
+ catalogue: BUNDLED_CATALOGUE,
646
+ label: onlyLabel,
647
+ sinceMs,
648
+ untilMs,
649
+ });
650
+ lines.push('', '--- against the previous log ---');
651
+ if (previous.total.calls === 0) {
652
+ lines.push('The previous log has nothing the pricing catalogue knows (under the same filters), '
653
+ + 'so there is no comparison to make — a different answer from zero growth.');
654
+ }
655
+ else {
656
+ const delta = total.totalUsd - previous.total.totalUsd;
657
+ const growthPct = previous.total.totalUsd > 0
658
+ ? ` (${delta >= 0 ? '+' : ''}${((delta / previous.total.totalUsd) * 100).toFixed(1)}%)`
659
+ : '';
660
+ lines.push(`Positive means the bill grew. ${formatUsd(previous.total.totalUsd)} → `
661
+ + `${formatUsd(total.totalUsd)}: ${delta >= 0 ? '+' : '-'}${formatUsd(Math.abs(delta))}${growthPct}, `
662
+ + `over ${count(previous.total.calls, 'call')} then and ${count(total.calls, 'call')} now `
663
+ + '— judge the call counts before the money.');
664
+ const describe = (d, shown) => d.was === null
665
+ ? `${d.delta >= 0 ? '+' : '-'}${formatUsd(Math.abs(d.delta))} ${shown} (new since the previous log)`
666
+ : d.now === null
667
+ ? `${d.delta >= 0 ? '+' : '-'}${formatUsd(Math.abs(d.delta))} ${shown} (gone since the previous log)`
668
+ : `${d.delta >= 0 ? '+' : '-'}${formatUsd(Math.abs(d.delta))} ${shown} (${formatUsd(d.was)} → ${formatUsd(d.now)})`;
669
+ for (const d of driversBetween(previous.byLabel.map((e) => ({ key: e.label, usd: e.breakdown.totalUsd })), report.byLabel.map((e) => ({ key: e.label, usd: e.breakdown.totalUsd }))).slice(0, 5)) {
670
+ lines.push(describe(d, name(d.key)));
671
+ }
672
+ const modelsInvolved = new Set([
673
+ ...previous.byModel.map((e) => e.model),
674
+ ...report.byModel.map((e) => e.model),
675
+ ]);
676
+ const modelDrivers = driversBetween(previous.byModel.map((e) => ({ key: e.model, usd: e.breakdown.totalUsd })), report.byModel.map((e) => ({ key: e.model, usd: e.breakdown.totalUsd })));
677
+ if (modelDrivers.length > 0 && modelsInvolved.size > 1) {
678
+ lines.push('The same change, by model — where the mix moved:');
679
+ for (const d of modelDrivers.slice(0, 3))
680
+ lines.push(describe(d, d.key));
681
+ }
682
+ }
683
+ }
684
+ /**
685
+ * The same request sent again a moment later — the pattern an agent
686
+ * harness produces when a step retries or loops. Named as a pattern:
687
+ * this reads counts and cannot see content.
688
+ */
689
+ if (report.repeatedTurns.length > 0) {
690
+ lines.push('');
691
+ lines.push('The same request, sent again:');
692
+ for (const row of report.repeatedTurns.slice(0, 3)) {
693
+ const shown = row.label === UNLABELLED ? 'unlabelled' : row.label;
694
+ lines.push(` ${shown} on ${row.model}: ${row.repeats} of ${row.checkedCalls} calls re-sent the `
695
+ + `previous call's exact input size within ${Math.round(row.withinMs / 1000)} seconds, `
696
+ + `in the same conversation, costing ${formatUsd(row.usd)}. A conversation's input `
697
+ + 'grows with every turn, so that is usually a retry, an agent step repeating, or a '
698
+ + 'loop — the log cannot see content, so the pattern is the claim and not the cause.');
699
+ }
700
+ }
701
+ /**
702
+ * How big the calls are, and how uneven that is.
703
+ *
704
+ * The agent asking "why is input 63% of this bill" needs the shape, not
705
+ * the share: an even slice wants a shorter prompt and a skewed one wants
706
+ * a cap on whatever is growing. Both figures are bucket ceilings, so the
707
+ * ratio is stated as approximate.
708
+ */
709
+ if (report.inputShapes.length > 0) {
710
+ lines.push('');
711
+ lines.push('How big these calls are:');
712
+ for (const shape of report.inputShapes.slice(0, 3)) {
713
+ const shown = shape.label === UNLABELLED ? 'unlabelled' : shape.label;
714
+ if (shape.medianWithinTokens === null || shape.p95WithinTokens === null || shape.p95OverMedian === null) {
715
+ lines.push(` ${shown} on ${shape.model}: every call is larger than this tool measures precisely `
716
+ + `(${formatUsd(shape.inputUsd)} of input spend). That size is itself the finding.`);
717
+ continue;
718
+ }
719
+ const cached = `${Math.round(shape.cachedShare * 100)}% of those tokens were cache reads`;
720
+ lines.push(` ${shown} on ${shape.model}: half its calls fit within `
721
+ + `${shape.medianWithinTokens.toLocaleString('en-US')} input tokens and 95% within `
722
+ + `${shape.p95WithinTokens.toLocaleString('en-US')} — about `
723
+ + `${shape.p95OverMedian.toFixed(1)}x the ordinary call, over `
724
+ + `${formatUsd(shape.inputUsd)} of input spend. ${cached}.`);
725
+ lines.push(shape.p95OverMedian >= 4
726
+ ? ' Past four times the median the ordinary call is fine and something is growing on '
727
+ + 'top of it — a conversation nobody truncates, a retrieval with no cap. The fix is a '
728
+ + 'limit on the large calls, not a rewrite of the prompt every call sends.'
729
+ : ' The large calls are not much larger than the ordinary one, so there is no tail to '
730
+ + 'cap: the prompt is simply big.');
731
+ }
732
+ }
733
+ /**
734
+ * `what_if`: the same tokens at another model's rates.
735
+ *
736
+ * The caveat leads, because an agent relaying only the dollar figure would
737
+ * turn multiplication into a recommendation. What cannot move is stated
738
+ * next to what would: calls over the target's context window would fail
739
+ * rather than cost less, and money already on the target is not a saving.
740
+ */
741
+ const whatIfModel = args.what_if;
742
+ if (whatIfModel !== undefined) {
743
+ if (typeof whatIfModel !== 'string')
744
+ throw new InvalidArguments('what_if must be a string');
745
+ const whatIf = repriceProfile(report, whatIfModel, BUNDLED_CATALOGUE);
746
+ if (whatIf === null) {
747
+ throw new InvalidArguments(`what_if names a model this catalogue cannot price: "${whatIfModel}". Priced models: `
748
+ + BUNDLED_CATALOGUE.models.map((m) => m.id).join(', '));
749
+ }
750
+ lines.push('');
751
+ lines.push(`These exact calls on ${whatIf.target.displayName}. This is multiplication, not advice: `
752
+ + 'the same token counts at another rate card. It says nothing about whether that '
753
+ + 'model could do the work, and a model that answers at greater length or gets '
754
+ + 'retried would not send these counts at all.');
755
+ if (whatIf.slices.length === 0) {
756
+ lines.push('Nothing to compare: every priced call is already on that model, or too large for '
757
+ + 'its context window.');
758
+ }
759
+ else {
760
+ const direction = whatIf.deltaUsd < 0 ? 'less' : 'more';
761
+ lines.push(`${formatUsd(whatIf.currentUsd)} of movable spend would have been `
762
+ + `${formatUsd(whatIf.targetUsd)} — ${formatUsd(Math.abs(whatIf.deltaUsd))} ${direction}.`);
763
+ for (const slice of whatIf.slices.slice(0, 5)) {
764
+ const shown = slice.label === UNLABELLED ? 'unlabelled' : slice.label;
765
+ lines.push(` ${shown} on ${slice.model}: ${formatUsd(slice.currentUsd)} → ${formatUsd(slice.targetUsd)}`);
766
+ }
767
+ }
768
+ for (const slice of whatIf.overContext.slice(0, 3)) {
769
+ const shown = slice.label === UNLABELLED ? 'unlabelled' : slice.label;
770
+ lines.push(`${shown} cannot move: its largest call carries ${slice.maxCallInputTokens.toLocaleString('en-US')} `
771
+ + `input tokens and that model's window is ${whatIf.target.contextWindow.toLocaleString('en-US')}. `
772
+ + `Those calls would fail, not cost less, so their ${formatUsd(slice.currentUsd)} is excluded above.`);
773
+ }
774
+ if (whatIf.alreadyOnTarget.calls > 0) {
775
+ lines.push(`Already on that model: ${whatIf.alreadyOnTarget.calls} calls worth `
776
+ + `${formatUsd(whatIf.alreadyOnTarget.usd)}, left out of the figures above — money that `
777
+ + 'cannot move would make the difference look smaller than it is.');
778
+ }
779
+ if (whatIf.unpricedCalls > 0) {
780
+ lines.push(`Excluded: ${whatIf.unpricedCalls} calls whose model has no price here `
781
+ + `(${whatIf.unpricedModels.join(', ')}). Their cost on the target is knowable; the `
782
+ + 'difference is not, because there is no current figure to subtract from.');
783
+ }
784
+ }
785
+ /**
786
+ * What this log cannot answer yet — the fields that unlock the findings
787
+ * an agent would otherwise ask for and not receive. Counts rather than
788
+ * booleans: twelve labelled records out of forty thousand is not a
789
+ * labelled log, and an agent told "labelled" would stop asking.
790
+ */
791
+ const coverage = report.fieldCoverage;
792
+ if (coverage.parsed > 0) {
793
+ const missing = [];
794
+ const seen = (count_) => `${count_}/${coverage.parsed}`;
795
+ if (coverage.label < coverage.parsed) {
796
+ missing.push(`"label" on ${seen(coverage.label)} records — per-workload spend and the drill-down`);
797
+ }
798
+ if (coverage.session < coverage.parsed) {
799
+ missing.push(`"session" on ${seen(coverage.session)} records — conversation growth, per-conversation cost, cache-TTL fit (grouped by, never shown)`);
800
+ }
801
+ if (coverage.ts < coverage.parsed) {
802
+ missing.push(`"ts" on ${seen(coverage.ts)} records — the period, the per-day and per-hour shape, and the cache-TTL question`);
803
+ }
804
+ if (coverage.stopReason < coverage.parsed) {
805
+ missing.push(`"stop_reason"/"finish_reason" on ${seen(coverage.stopReason)} records — answers cut off at max_tokens`);
806
+ }
807
+ if (coverage.cacheWrites > 0 && coverage.cacheTtl < coverage.cacheWrites) {
808
+ missing.push(`the "cache_creation" object on ${coverage.cacheTtl}/${coverage.cacheWrites} of the records that wrote to the cache — otherwise the cheaper rate is assumed and those totals are a floor`);
809
+ }
810
+ if (missing.length > 0) {
811
+ lines.push('', '--- what this log cannot answer yet ---');
812
+ for (const line of missing)
813
+ lines.push(line);
814
+ }
815
+ }
816
+ if (report.unpricedModels.length > 0 || report.skippedLines.length > 0) {
817
+ lines.push('', '--- gaps ---');
818
+ gaps();
819
+ }
820
+ return lines.join('\n');
821
+ },
822
+ };
211
823
  /** The whole surface. An exact list, asserted as one by the tests. */
212
- export const TOOLS = [OPTIMIZE, CHECK, MODELS];
824
+ export const TOOLS = [OPTIMIZE, CHECK, MODELS, PROFILE];
213
825
  //# sourceMappingURL=tools.js.map