@trazum/core 1.50.3 → 1.50.5

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/usage.ts CHANGED
@@ -5,6 +5,7 @@ import { createInputShapeTracker } from './input-shape.js';
5
5
  import { createRepeatsTracker } from './repeats.js';
6
6
  import { createTruncationRetryTracker } from './truncation-retry.js';
7
7
  import { createTtlFitTracker } from './ttl-fit.js';
8
+ import type { OutcomeTally } from './outcome.js';
8
9
  import { createSessionLedgerTracker } from './session-ledger.js';
9
10
  import { createSessionCostTracker } from './session-cost.js';
10
11
  import type { SessionCostShape } from './session-cost.js';
@@ -111,6 +112,17 @@ export interface UsageRecord {
111
112
  * refuses to read a log until it is annotated is a profile nobody runs.
112
113
  */
113
114
  label: string | null;
115
+ /**
116
+ * What happened, in the product's own vocabulary — `resolved`, `escalated`,
117
+ * `thumbs-down`, whatever the words are.
118
+ *
119
+ * The counterpart every other figure here has been missing. **Recorded by
120
+ * the caller and never inferred**: no absence of complaint counts as
121
+ * success, no short conversation counts as resolution, no retry counts as
122
+ * failure. `null` is "not recorded" and is reported as such, which is a
123
+ * different answer from any outcome and is treated as one everywhere.
124
+ */
125
+ outcome: string | null;
114
126
  /**
115
127
  * Optional conversation identifier, for measuring what re-sent history costs.
116
128
  *
@@ -241,6 +253,8 @@ export interface FieldCoverage {
241
253
  label: number;
242
254
  /** Records with a usable `session` or `conversation_id`. */
243
255
  session: number;
256
+ /** Records with a usable `outcome` or `trazum_outcome`. */
257
+ outcome: number;
244
258
  /** Records with a readable timestamp. */
245
259
  ts: number;
246
260
  /** Records with a `stop_reason` or `finish_reason`. */
@@ -426,6 +440,23 @@ export interface UsageProfileReport {
426
440
  * and a boolean would call it that.
427
441
  */
428
442
  fieldCoverage: FieldCoverage;
443
+ /**
444
+ * The same tally per label, and per model.
445
+ *
446
+ * The groupings a decision is made at. "This workload costs more per
447
+ * resolution than that one" is the finding a total cannot make, and it needs
448
+ * the numerator sliced the same way the bill already is.
449
+ */
450
+ outcomeTallyByLabel: Array<{ label: string; calls: number; totalUsd: number; tally: OutcomeTally }>;
451
+ outcomeTallyByModel: Array<{ model: string; calls: number; totalUsd: number; tally: OutcomeTally }>;
452
+ /**
453
+ * What each recorded outcome cost — measurement only, no judgement.
454
+ *
455
+ * `outcomeReport` turns this into a success rate where the config declares
456
+ * one, because which values mean success is a product judgement this module
457
+ * has no standing to make. An aggregate, never a list of calls.
458
+ */
459
+ outcomeTally: OutcomeTally;
429
460
  /**
430
461
  * Spend per hour of the UTC day, 0–23, over priced records that carry a
431
462
  * clock — and only the hours that saw traffic.
@@ -778,6 +809,13 @@ export function parseUsageLine(line: string): UsageRecord | null {
778
809
  * nobody sets measures nothing.
779
810
  */
780
811
  session: nameOf(record.session) ?? nameOf(record.conversation_id),
812
+ /**
813
+ * Read from either spelling, for the same reason `session` is: `outcome`
814
+ * in a log somebody wrote for this, `trazum_outcome` in one where a
815
+ * namespace was wanted. A field nobody sets measures nothing, and making
816
+ * its adoption a chore is how that happens.
817
+ */
818
+ outcome: nameOf(record.outcome) ?? nameOf(record.trazum_outcome),
781
819
  ts: moment.kind === 'ok' ? moment.ms : null,
782
820
  /**
783
821
  * Anthropic spells it `stop_reason: "max_tokens"`, OpenAI
@@ -795,6 +833,28 @@ export function parseUsageLine(line: string): UsageRecord | null {
795
833
  };
796
834
  }
797
835
 
836
+ /**
837
+ * One slice's outcome tally, in the same shape as the whole log's.
838
+ *
839
+ * `parsed` is the slice's own call count rather than the log's, so a coverage
840
+ * share computed from it describes this slice and not the file it came from.
841
+ */
842
+ function tallyOf(
843
+ buckets: Map<string, { calls: number; usd: number }> | undefined,
844
+ unrecordedUsd: number,
845
+ parsed: number,
846
+ ): OutcomeTally {
847
+ const byValue = [...(buckets ?? new Map()).entries()]
848
+ .map(([value, bucket]) => ({ value, calls: bucket.calls, usd: bucket.usd }))
849
+ .sort((a, b) => b.usd - a.usd);
850
+ return {
851
+ byValue,
852
+ recorded: byValue.reduce((sum, entry) => sum + entry.calls, 0),
853
+ parsed,
854
+ unrecordedUsd,
855
+ };
856
+ }
857
+
798
858
  /**
799
859
  * A label or session identifier, from whatever a real log holds.
800
860
  *
@@ -977,7 +1037,14 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
977
1037
  const ledger = createSessionLedgerTracker({ catalogue, on });
978
1038
  const sessionCosts = createSessionCostTracker({ catalogue, on });
979
1039
  let hasSessions = false;
980
- const coverage = { label: 0, session: 0, ts: 0, stopReason: 0, cacheTtl: 0, cacheWrites: 0, parsed: 0 };
1040
+ const outcomes = new Map<string, { calls: number; usd: number }>();
1041
+ let unrecordedOutcomeUsd = 0;
1042
+ /** Per-slice outcome tallies, keyed by label and by model. */
1043
+ const outcomesByLabel = new Map<string, Map<string, { calls: number; usd: number }>>();
1044
+ const outcomesByModel = new Map<string, Map<string, { calls: number; usd: number }>>();
1045
+ const unrecordedByLabel = new Map<string, number>();
1046
+ const unrecordedByModel = new Map<string, number>();
1047
+ const coverage = { label: 0, session: 0, outcome: 0, ts: 0, stopReason: 0, cacheTtl: 0, cacheWrites: 0, parsed: 0 };
981
1048
  /**
982
1049
  * Raw lines already seen, for the duplicate check. Bounded by the number of
983
1050
  * *timestamped* lines — the price of catching a doubled bill, paid only on
@@ -1040,6 +1107,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1040
1107
  coverage.parsed += 1;
1041
1108
  if (record.label !== null) coverage.label += 1;
1042
1109
  if (record.session !== null) coverage.session += 1;
1110
+ if (record.outcome !== null) coverage.outcome += 1;
1043
1111
  if (record.ts !== null) coverage.ts += 1;
1044
1112
  if (record.truncated !== null) coverage.stopReason += 1;
1045
1113
  if (record.cacheWrite5mTokens + record.cacheWrite1hTokens > 0) {
@@ -1095,6 +1163,41 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1095
1163
  if (record.session !== null) {
1096
1164
  sessionUsd.set(record.session, (sessionUsd.get(record.session) ?? 0) + (total.totalUsd - usdBefore));
1097
1165
  }
1166
+ /**
1167
+ * Tallied from the same per-record dollar as everything else here, so a
1168
+ * success rate by spend and the bill it is a share of can never be two
1169
+ * different arithmetics that drifted apart.
1170
+ */
1171
+ {
1172
+ const usd = total.totalUsd - usdBefore;
1173
+ if (record.outcome === null) {
1174
+ unrecordedOutcomeUsd += usd;
1175
+ } else {
1176
+ const bucket = outcomes.get(record.outcome) ?? { calls: 0, usd: 0 };
1177
+ bucket.calls += 1;
1178
+ bucket.usd += usd;
1179
+ outcomes.set(record.outcome, bucket);
1180
+ }
1181
+
1182
+ const into = (
1183
+ map: Map<string, Map<string, { calls: number; usd: number }>>,
1184
+ unrecorded: Map<string, number>,
1185
+ key: string,
1186
+ ): void => {
1187
+ if (record.outcome === null) {
1188
+ unrecorded.set(key, (unrecorded.get(key) ?? 0) + usd);
1189
+ return;
1190
+ }
1191
+ const slice = map.get(key) ?? new Map<string, { calls: number; usd: number }>();
1192
+ const bucket = slice.get(record.outcome) ?? { calls: 0, usd: 0 };
1193
+ bucket.calls += 1;
1194
+ bucket.usd += usd;
1195
+ slice.set(record.outcome, bucket);
1196
+ map.set(key, slice);
1197
+ };
1198
+ into(outcomesByLabel, unrecordedByLabel, record.label ?? UNLABELLED);
1199
+ into(outcomesByModel, unrecordedByModel, record.model);
1200
+ }
1098
1201
  if (record.ts !== null) {
1099
1202
  const day = new Date(record.ts).toISOString().slice(0, 10);
1100
1203
  const usd = total.totalUsd - usdBefore;
@@ -1189,6 +1292,34 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1189
1292
  }),
1190
1293
  duplicateLines: duplicates,
1191
1294
  fieldCoverage: coverage,
1295
+ outcomeTally: {
1296
+ byValue: [...outcomes.entries()]
1297
+ .map(([value, bucket]) => ({ value, calls: bucket.calls, usd: bucket.usd }))
1298
+ .sort((a, b) => b.usd - a.usd),
1299
+ recorded: coverage.outcome,
1300
+ parsed: coverage.parsed,
1301
+ unrecordedUsd: unrecordedOutcomeUsd,
1302
+ },
1303
+ outcomeTallyByLabel: sorted(byLabel, 'label').map((entry) => ({
1304
+ label: entry.label,
1305
+ calls: entry.breakdown.calls,
1306
+ totalUsd: entry.breakdown.totalUsd,
1307
+ tally: tallyOf(
1308
+ outcomesByLabel.get(entry.label),
1309
+ unrecordedByLabel.get(entry.label) ?? 0,
1310
+ entry.breakdown.calls,
1311
+ ),
1312
+ })),
1313
+ outcomeTallyByModel: sorted(byModel, 'model').map((entry) => ({
1314
+ model: entry.model,
1315
+ calls: entry.breakdown.calls,
1316
+ totalUsd: entry.breakdown.totalUsd,
1317
+ tally: tallyOf(
1318
+ outcomesByModel.get(entry.model),
1319
+ unrecordedByModel.get(entry.model) ?? 0,
1320
+ entry.breakdown.calls,
1321
+ ),
1322
+ })),
1192
1323
  modelMixDrift: (() => {
1193
1324
  const ordered = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1194
1325
  if (ordered.length < 4) return null;