@trazum/core 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { UNLABELLED } from './usage.js';
3
+ import type { PricingCatalogue } from './pricing.js';
4
+ import type { UsageRecord } from './usage.js';
5
+
6
+ /**
7
+ * The retry bill of truncation.
8
+ *
9
+ * ## The half of the cost the truncation line could not see
10
+ *
11
+ * `truncatedOutputUsd` prices the answers cut off at `max_tokens` — paid in
12
+ * full, and the attempt bought nothing. The sentence next to it has always
13
+ * said "frequently retried, billed again", and that second half was an
14
+ * assertion rather than a measurement: nothing counted the retries.
15
+ *
16
+ * This does. A truncated answer followed **within a couple of minutes by
17
+ * another call in the same conversation** is the shape a retry has — the
18
+ * user or the harness asked again, usually with a raised ceiling — and both
19
+ * sides of that pair are in the log. The first call's full price is money
20
+ * that bought a cut-off answer; the follow-up is the same question billed a
21
+ * second time, at conversation prices, with the history re-sent.
22
+ *
23
+ * ## What it compares, and why only that
24
+ *
25
+ * Each call is checked against the one immediately before it in the same
26
+ * session — the bounded-memory design `repeats.ts` uses, for the same
27
+ * reasons — and only when the previous call was truncated and the gap is
28
+ * non-negative and inside the window. Two minutes by default, wider than the
29
+ * repeat window: a human rephrasing after a cut-off answer takes longer than
30
+ * a harness retrying a timeout.
31
+ *
32
+ * ## What it refuses to conclude
33
+ *
34
+ * It cannot see content, so it cannot tell a retry from a user changing the
35
+ * subject right after a truncated answer. The pair is a pattern, stated as
36
+ * one. A single pair is not reported — one retry is an anecdote — and the
37
+ * per-slice denominator (`truncatedCalls` that *could* be checked) travels
38
+ * with the count, so "3 of 40" and "3 of 3" read as differently as they are.
39
+ */
40
+
41
+ /** Truncated answers followed by another call in the same conversation. */
42
+ export interface TruncationRetry {
43
+ label: string;
44
+ model: string;
45
+ modelName: string;
46
+ /** Truncated calls that were followed up inside the window. */
47
+ retried: number;
48
+ /** Truncated calls in this slice that carried a session and a clock. */
49
+ truncatedCalls: number;
50
+ /** The full price of the truncated attempts that were followed up. */
51
+ wastedUsd: number;
52
+ /** The full price of the follow-up calls. */
53
+ retryUsd: number;
54
+ /** The window the follow-up had to fall inside, in milliseconds. */
55
+ withinMs: number;
56
+ }
57
+
58
+ export interface TruncationRetryOptions {
59
+ catalogue: PricingCatalogue;
60
+ on?: Date;
61
+ /**
62
+ * How close the follow-up has to be. Two minutes by default — a human
63
+ * rephrasing after a cut-off answer takes longer than a harness retrying,
64
+ * and past a couple of minutes the next call is a next question.
65
+ */
66
+ withinMs?: number;
67
+ /** Slices below this many retried pairs are dropped. Default 2. */
68
+ minRetried?: number;
69
+ }
70
+
71
+ export interface TruncationRetryTracker {
72
+ add(record: UsageRecord): void;
73
+ finish(): TruncationRetry[];
74
+ }
75
+
76
+ interface Slice {
77
+ retried: number;
78
+ truncatedCalls: number;
79
+ wastedUsd: number;
80
+ retryUsd: number;
81
+ }
82
+
83
+ /** An accumulator, fed in the pass a profile already makes. */
84
+ export function createTruncationRetryTracker(options: TruncationRetryOptions): TruncationRetryTracker {
85
+ const { catalogue, on = new Date(), withinMs = 120_000, minRetried = 2 } = options;
86
+ const slices = new Map<string, Slice>();
87
+ /** The previous call of each session: whether it truncated, when, its price, and whose slice it was. */
88
+ const previous = new Map<string, { truncated: boolean; ts: number; usd: number; key: string }>();
89
+
90
+ const priceOf = (record: UsageRecord): number | null => {
91
+ const model = catalogue.byId.get(record.model);
92
+ if (!model) return null;
93
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
94
+ const rates = multipliersFor(model);
95
+ const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
96
+ return (
97
+ per(record.inputTokens, inputPerMTok) +
98
+ per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
99
+ per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
100
+ per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
101
+ per(record.outputTokens, outputPerMTok)
102
+ );
103
+ };
104
+
105
+ const add = (record: UsageRecord): void => {
106
+ if (record.session === null || record.ts === null) return;
107
+ const usd = priceOf(record);
108
+ // An unpriced model has no dollars anywhere else in the report; a retry
109
+ // bill stated for it would be a figure with nothing behind it.
110
+ if (usd === null) return;
111
+
112
+ const key = `${record.label ?? UNLABELLED}\n${record.model}`;
113
+ const before = previous.get(record.session);
114
+ previous.set(record.session, { truncated: record.truncated === true, ts: record.ts, usd, key });
115
+
116
+ if (record.truncated === true) {
117
+ let slice = slices.get(key);
118
+ if (!slice) {
119
+ slice = { retried: 0, truncatedCalls: 0, wastedUsd: 0, retryUsd: 0 };
120
+ slices.set(key, slice);
121
+ }
122
+ slice.truncatedCalls += 1;
123
+ }
124
+
125
+ if (before === undefined || !before.truncated) return;
126
+ const gap = record.ts - before.ts;
127
+ // Out of order is not a retry, and neither is tomorrow's next question.
128
+ if (gap < 0 || gap >= withinMs) return;
129
+
130
+ // Attributed to the slice of the *truncated* call: that is where the
131
+ // ceiling that caused this lives, and where the fix is applied.
132
+ let slice = slices.get(before.key);
133
+ if (!slice) {
134
+ slice = { retried: 0, truncatedCalls: 0, wastedUsd: 0, retryUsd: 0 };
135
+ slices.set(before.key, slice);
136
+ }
137
+ slice.retried += 1;
138
+ slice.wastedUsd += before.usd;
139
+ slice.retryUsd += usd;
140
+ };
141
+
142
+ const finish = (): TruncationRetry[] => {
143
+ const out: TruncationRetry[] = [];
144
+ for (const [key, slice] of slices) {
145
+ if (slice.retried < minRetried) continue;
146
+ const split = key.indexOf('\n');
147
+ const modelId = key.slice(split + 1);
148
+ const model = catalogue.byId.get(modelId);
149
+ if (!model) continue;
150
+ out.push({
151
+ label: key.slice(0, split),
152
+ model: modelId,
153
+ modelName: model.displayName,
154
+ retried: slice.retried,
155
+ truncatedCalls: slice.truncatedCalls,
156
+ wastedUsd: slice.wastedUsd,
157
+ retryUsd: slice.retryUsd,
158
+ withinMs,
159
+ });
160
+ }
161
+ return out.sort((a, b) => b.wastedUsd + b.retryUsd - (a.wastedUsd + a.retryUsd));
162
+ };
163
+
164
+ return { add, finish };
165
+ }
166
+
167
+ /** The same measurement over a list of records, for a caller holding one. */
168
+ export function truncationRetries(
169
+ records: readonly UsageRecord[],
170
+ options: TruncationRetryOptions,
171
+ ): TruncationRetry[] {
172
+ const tracker = createTruncationRetryTracker(options);
173
+ for (const record of records) tracker.add(record);
174
+ return tracker.finish();
175
+ }
package/src/usage.ts CHANGED
@@ -3,6 +3,7 @@ import { createConversationTracker } from './conversation.js';
3
3
  import { createOutputShapeTracker } from './output-shape.js';
4
4
  import { createInputShapeTracker } from './input-shape.js';
5
5
  import { createRepeatsTracker } from './repeats.js';
6
+ import { createTruncationRetryTracker } from './truncation-retry.js';
6
7
  import { createTtlFitTracker } from './ttl-fit.js';
7
8
  import { createSessionLedgerTracker } from './session-ledger.js';
8
9
  import { createSessionCostTracker } from './session-cost.js';
@@ -13,6 +14,7 @@ import type { ConversationGrowth } from './conversation.js';
13
14
  import type { OutputShape } from './output-shape.js';
14
15
  import type { InputShape } from './input-shape.js';
15
16
  import type { RepeatedTurns } from './repeats.js';
17
+ import type { TruncationRetry } from './truncation-retry.js';
16
18
  import type { PricingCatalogue } from './pricing.js';
17
19
 
18
20
  /**
@@ -313,6 +315,15 @@ export interface UsageProfileReport {
313
315
  * carries neither, which is a different statement from "none happened".
314
316
  */
315
317
  repeatedTurns: RepeatedTurns[];
318
+ /**
319
+ * Truncated answers followed within two minutes by another call in the
320
+ * same conversation — the "frequently retried, billed again" half of the
321
+ * truncation finding, measured instead of asserted. The wasted attempt's
322
+ * full price and the follow-up's travel together, with the checkable
323
+ * denominator. Needs a session, a clock and a stop reason; a pattern,
324
+ * never a certainty — the log cannot see content.
325
+ */
326
+ truncationRetries: TruncationRetry[];
316
327
  /**
317
328
  * The period the log covers, when its records carry a clock, over every
318
329
  * parsed record — priced and unpriced alike, because when a call happened is
@@ -346,6 +357,12 @@ export interface UsageProfileReport {
346
357
  /** The label that spent the most this day, or null when nothing had one. */
347
358
  topLabel: string | null;
348
359
  topLabelUsd: number;
360
+ /**
361
+ * The day's spend per model, largest first — the series `modelMixDrift`
362
+ * summarises, exposed whole so a spreadsheet or a chart can draw the
363
+ * migration day by day instead of in two halves.
364
+ */
365
+ byModel: Array<{ model: string; usd: number; calls: number }>;
349
366
  }>;
350
367
  /**
351
368
  * Lines that are exact duplicates of an earlier line, and what they added
@@ -417,6 +434,35 @@ export interface UsageProfileReport {
417
434
  * would be guessing.
418
435
  */
419
436
  spendByHour: Array<{ hour: number; usd: number; calls: number }>;
437
+ /**
438
+ * How the model mix moved across this log's own span — the drift `--against`
439
+ * can only see with a second log.
440
+ *
441
+ * A bill can grow with no workload growing: traffic quietly migrating from
442
+ * the cheap model to the expensive one, a deploy that flipped a default, a
443
+ * fallback that became the main path. Day totals cannot show it (both
444
+ * models land in the same number) and per-model totals cannot either (a
445
+ * total has no direction). So this splits the log's days into two halves,
446
+ * chronologically, and states each model's **share of the priced spend** in
447
+ * each half, exactly.
448
+ *
449
+ * `null` — not empty — when the log has fewer than four days with priced,
450
+ * dated spend: two points per half is the least a "half" can honestly
451
+ * claim, and a drift computed over one day against one day would be
452
+ * weather presented as climate. The renderings decide what counts as
453
+ * "moved"; the data states the shares and stops. No forecast: where the
454
+ * mix goes next is not in the log.
455
+ */
456
+ modelMixDrift: {
457
+ /** Days in each half. `firstDays + lastDays` = every day with priced spend. */
458
+ firstDays: number;
459
+ lastDays: number;
460
+ /** Priced spend in each half, so a share can be turned back into money. */
461
+ firstUsd: number;
462
+ lastUsd: number;
463
+ /** Per model, every model either half saw. Shares are of that half's spend. */
464
+ models: Array<{ model: string; firstShare: number; lastShare: number; firstUsd: number; lastUsd: number }>;
465
+ } | null;
420
466
  /**
421
467
  * Whether each slice's cache TTL fits how fast its turns arrive — the
422
468
  * mechanism behind a losing cache verdict, and the one place an overlong TTL
@@ -876,6 +922,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
876
922
  const output = createOutputShapeTracker({ catalogue, on });
877
923
  const input = createInputShapeTracker({ catalogue, on });
878
924
  const repeats = createRepeatsTracker({ catalogue, on });
925
+ const truncRetries = createTruncationRetryTracker({ catalogue, on });
879
926
  const ttlFit = createTtlFitTracker({ catalogue, on });
880
927
  const ledger = createSessionLedgerTracker({ catalogue, on });
881
928
  const sessionCosts = createSessionCostTracker({ catalogue, on });
@@ -892,7 +939,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
892
939
  let spanTo = -Infinity;
893
940
  let spanCalls = 0;
894
941
  /** Per UTC day: spend, calls, and spend per label. Bounded by days × labels. */
895
- const days = new Map<string, { usd: number; calls: number; byLabel: Map<string, number> }>();
942
+ const days = new Map<string, { usd: number; calls: number; byLabel: Map<string, number>; byModel: Map<string, { usd: number; calls: number }> }>();
896
943
  /** Per hour of the UTC day. Bounded by twenty-four entries, whatever the log. */
897
944
  const hours = new Map<number, { usd: number; calls: number }>();
898
945
 
@@ -952,6 +999,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
952
999
  output.add(record);
953
1000
  input.add(record);
954
1001
  repeats.add(record);
1002
+ truncRetries.add(record);
955
1003
  ttlFit.add(record);
956
1004
  ledger.add(record);
957
1005
  sessionCosts.add(record);
@@ -991,13 +1039,17 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
991
1039
  const usd = total.totalUsd - usdBefore;
992
1040
  let entry = days.get(day);
993
1041
  if (!entry) {
994
- entry = { usd: 0, calls: 0, byLabel: new Map() };
1042
+ entry = { usd: 0, calls: 0, byLabel: new Map(), byModel: new Map() };
995
1043
  days.set(day, entry);
996
1044
  }
997
1045
  entry.usd += usd;
998
1046
  entry.calls += 1;
999
1047
  const labelKey = record.label ?? UNLABELLED;
1000
1048
  entry.byLabel.set(labelKey, (entry.byLabel.get(labelKey) ?? 0) + usd);
1049
+ const modelCell = entry.byModel.get(record.model) ?? { usd: 0, calls: 0 };
1050
+ modelCell.usd += usd;
1051
+ modelCell.calls += 1;
1052
+ entry.byModel.set(record.model, modelCell);
1001
1053
 
1002
1054
  // The same exact per-record dollar, bucketed by hour of the UTC day.
1003
1055
  const hour = new Date(record.ts).getUTCHours();
@@ -1050,6 +1102,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1050
1102
  outputShapes: output.finish(total.totalUsd),
1051
1103
  inputShapes: input.finish(total.totalUsd),
1052
1104
  repeatedTurns: repeats.finish(),
1105
+ truncationRetries: truncRetries.finish(),
1053
1106
  span: spanCalls > 0 ? { fromMs: spanFrom, toMs: spanTo, calls: spanCalls } : null,
1054
1107
  spendByDay: [...days.entries()]
1055
1108
  .sort((a, b) => a[0].localeCompare(b[0]))
@@ -1062,10 +1115,56 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1062
1115
  topLabelUsd = usd;
1063
1116
  }
1064
1117
  }
1065
- return { day, usd: entry.usd, calls: entry.calls, topLabel, topLabelUsd };
1118
+ return {
1119
+ day,
1120
+ usd: entry.usd,
1121
+ calls: entry.calls,
1122
+ topLabel,
1123
+ topLabelUsd,
1124
+ byModel: [...entry.byModel.entries()]
1125
+ .map(([model, cell]) => ({ model, usd: cell.usd, calls: cell.calls }))
1126
+ .sort((a, b) => b.usd - a.usd),
1127
+ };
1066
1128
  }),
1067
1129
  duplicateLines: duplicates,
1068
1130
  fieldCoverage: coverage,
1131
+ modelMixDrift: (() => {
1132
+ const ordered = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1133
+ if (ordered.length < 4) return null;
1134
+ const mid = Math.floor(ordered.length / 2);
1135
+ const halves = [ordered.slice(0, mid), ordered.slice(mid)] as const;
1136
+ const totals = halves.map((half) => half.reduce((sum, [, e]) => sum + e.usd, 0));
1137
+ // A half with no priced spend has no shares to state — division by zero
1138
+ // is not a drift, and neither is a mix over zero dollars.
1139
+ if (totals[0]! <= 0 || totals[1]! <= 0) return null;
1140
+ const perModel = new Map<string, { first: number; last: number }>();
1141
+ halves.forEach((half, index) => {
1142
+ for (const [, entry] of half) {
1143
+ for (const [model, dayCell] of entry.byModel) {
1144
+ const cell = perModel.get(model) ?? { first: 0, last: 0 };
1145
+ if (index === 0) cell.first += dayCell.usd;
1146
+ else cell.last += dayCell.usd;
1147
+ perModel.set(model, cell);
1148
+ }
1149
+ }
1150
+ });
1151
+ return {
1152
+ firstDays: halves[0].length,
1153
+ lastDays: halves[1].length,
1154
+ firstUsd: totals[0]!,
1155
+ lastUsd: totals[1]!,
1156
+ models: [...perModel.entries()]
1157
+ .map(([model, cell]) => ({
1158
+ model,
1159
+ firstShare: cell.first / totals[0]!,
1160
+ lastShare: cell.last / totals[1]!,
1161
+ firstUsd: cell.first,
1162
+ lastUsd: cell.last,
1163
+ }))
1164
+ // The biggest movement first — the order a reader would act in.
1165
+ .sort((a, b) => Math.abs(b.lastShare - b.firstShare) - Math.abs(a.lastShare - a.firstShare)),
1166
+ };
1167
+ })(),
1069
1168
  spendByHour: [...hours.entries()]
1070
1169
  .sort((a, b) => a[0] - b[0])
1071
1170
  .map(([hour, entry]) => ({ hour, usd: entry.usd, calls: entry.calls })),