@trazum/core 1.10.0 → 1.26.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.
Files changed (84) hide show
  1. package/dist/against.d.ts +32 -0
  2. package/dist/against.d.ts.map +1 -0
  3. package/dist/against.js +34 -0
  4. package/dist/against.js.map +1 -0
  5. package/dist/config-schema.d.ts +42 -1
  6. package/dist/config-schema.d.ts.map +1 -1
  7. package/dist/config-schema.js +61 -0
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/conversation.d.ts +121 -0
  10. package/dist/conversation.d.ts.map +1 -0
  11. package/dist/conversation.js +157 -0
  12. package/dist/conversation.js.map +1 -0
  13. package/dist/csv.d.ts +61 -0
  14. package/dist/csv.d.ts.map +1 -0
  15. package/dist/csv.js +149 -0
  16. package/dist/csv.js.map +1 -0
  17. package/dist/evaluate.d.ts +24 -0
  18. package/dist/evaluate.d.ts.map +1 -1
  19. package/dist/evaluate.js +5 -2
  20. package/dist/evaluate.js.map +1 -1
  21. package/dist/index.d.ts +25 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +28 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/input-shape.d.ts +104 -0
  26. package/dist/input-shape.d.ts.map +1 -0
  27. package/dist/input-shape.js +132 -0
  28. package/dist/input-shape.js.map +1 -0
  29. package/dist/levers.d.ts +151 -0
  30. package/dist/levers.d.ts.map +1 -0
  31. package/dist/levers.js +160 -0
  32. package/dist/levers.js.map +1 -0
  33. package/dist/node.d.ts +1 -1
  34. package/dist/node.d.ts.map +1 -1
  35. package/dist/output-shape.d.ts +96 -0
  36. package/dist/output-shape.d.ts.map +1 -0
  37. package/dist/output-shape.js +145 -0
  38. package/dist/output-shape.js.map +1 -0
  39. package/dist/pricing-overlay.d.ts +1 -1
  40. package/dist/pricing-overlay.d.ts.map +1 -1
  41. package/dist/pricing-overlay.js +46 -0
  42. package/dist/pricing-overlay.js.map +1 -1
  43. package/dist/repeats.d.ts +75 -0
  44. package/dist/repeats.d.ts.map +1 -0
  45. package/dist/repeats.js +82 -0
  46. package/dist/repeats.js.map +1 -0
  47. package/dist/reprice.d.ts +143 -0
  48. package/dist/reprice.d.ts.map +1 -0
  49. package/dist/reprice.js +82 -0
  50. package/dist/reprice.js.map +1 -0
  51. package/dist/session-cost.d.ts +70 -0
  52. package/dist/session-cost.d.ts.map +1 -0
  53. package/dist/session-cost.js +90 -0
  54. package/dist/session-cost.js.map +1 -0
  55. package/dist/session-ledger.d.ts +77 -0
  56. package/dist/session-ledger.d.ts.map +1 -0
  57. package/dist/session-ledger.js +99 -0
  58. package/dist/session-ledger.js.map +1 -0
  59. package/dist/ttl-fit.d.ts +103 -0
  60. package/dist/ttl-fit.d.ts.map +1 -0
  61. package/dist/ttl-fit.js +184 -0
  62. package/dist/ttl-fit.js.map +1 -0
  63. package/dist/usage.d.ts +434 -16
  64. package/dist/usage.d.ts.map +1 -1
  65. package/dist/usage.js +383 -23
  66. package/dist/usage.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/against.ts +48 -0
  69. package/src/config-schema.ts +106 -0
  70. package/src/conversation.ts +305 -0
  71. package/src/csv.ts +184 -0
  72. package/src/evaluate.ts +33 -3
  73. package/src/index.ts +51 -1
  74. package/src/input-shape.ts +259 -0
  75. package/src/levers.ts +331 -0
  76. package/src/node.ts +1 -1
  77. package/src/output-shape.ts +254 -0
  78. package/src/pricing-overlay.ts +52 -1
  79. package/src/repeats.ts +166 -0
  80. package/src/reprice.ts +227 -0
  81. package/src/session-cost.ts +170 -0
  82. package/src/session-ledger.ts +189 -0
  83. package/src/ttl-fit.ts +251 -0
  84. package/src/usage.ts +795 -7
package/src/evaluate.ts CHANGED
@@ -41,6 +41,16 @@ export type EvalVerdict = 'indistinguishable' | 'within-noise' | 'diverges' | 'i
41
41
  export interface EvalReport {
42
42
  provider: string;
43
43
  model: string;
44
+ /**
45
+ * The model the candidate answer came from.
46
+ *
47
+ * Equal to `model` on the ordinary comparison — same model, two prompts. It
48
+ * differs when the question is the other one: **same prompt, two models**, which
49
+ * is what a routing decision is. `profile` prices that route exactly and can say
50
+ * nothing at all about whether the cheaper model still does the job; this is the
51
+ * measurement that can.
52
+ */
53
+ candidateModel: string;
44
54
  cases: EvalCase[];
45
55
  /** Mean agreement of the original prompt with itself. The yardstick. */
46
56
  selfAgreement: number;
@@ -58,6 +68,20 @@ export interface EvaluateOptions {
58
68
  * call already paid for.
59
69
  */
60
70
  concurrency?: number;
71
+ /**
72
+ * Where the candidate answer comes from. Defaults to `provider`.
73
+ *
74
+ * This is the whole routing axis, and it needed no new yardstick. The baseline
75
+ * prompt is still run **twice on the original model** to measure that model's own
76
+ * variance, and the candidate is still judged against it — so the question
77
+ * becomes "does the cheaper model agree with the expensive one more closely than
78
+ * the expensive one agrees with itself?", which is the honest form of "is this
79
+ * route safe".
80
+ *
81
+ * A verdict built any other way would be a threshold somebody picked. This one is
82
+ * the model's own noise floor, measured on the same cases in the same run.
83
+ */
84
+ candidateProvider?: LlmProvider;
61
85
  }
62
86
 
63
87
  /**
@@ -149,9 +173,13 @@ export async function evaluate(
149
173
  options: EvaluateOptions = {},
150
174
  ): Promise<EvalReport> {
151
175
  const concurrency = Math.max(1, options.concurrency ?? 3);
176
+ const candidate = options.candidateProvider ?? provider;
152
177
 
153
- const run = (prompt: string, input: string): Promise<string> =>
154
- provider.complete({ system: fillPrompt(prompt, input), user: input });
178
+ const run = (
179
+ prompt: string,
180
+ input: string,
181
+ on: LlmProvider = provider,
182
+ ): Promise<string> => on.complete({ system: fillPrompt(prompt, input), user: input });
155
183
 
156
184
  const cases = await pooled(
157
185
  inputs.map((input) => async (): Promise<EvalCase> => {
@@ -160,7 +188,8 @@ export async function evaluate(
160
188
  // serve one from a cache and report a variance of zero.
161
189
  const baselineA = await run(originalPrompt, input);
162
190
  const baselineB = await run(originalPrompt, input);
163
- const optimized = await run(optimizedPrompt, input);
191
+ // On `candidate`, which is `provider` unless a route is being measured.
192
+ const optimized = await run(optimizedPrompt, input, candidate);
164
193
 
165
194
  return {
166
195
  input,
@@ -179,6 +208,7 @@ export async function evaluate(
179
208
  return {
180
209
  provider: provider.name,
181
210
  model: provider.model,
211
+ candidateModel: candidate.model,
182
212
  cases,
183
213
  selfAgreement,
184
214
  crossAgreement,
package/src/index.ts CHANGED
@@ -2,18 +2,68 @@ export * from './types.js';
2
2
  export { ESTIMATE_ERROR_BAND_PCT, estimateTokens, countTokensAnthropic } from './tokenizer.js';
3
3
  export {
4
4
  UNLABELLED,
5
+ cacheEconomics,
5
6
  cacheHitRate,
6
7
  parseUsageLine,
7
8
  profileUsage,
8
9
  sharesOf,
9
10
  } from './usage.js';
10
11
  export type {
12
+ CacheEconomics,
13
+ CacheVerdict,
11
14
  UsageProfileOptions,
12
15
  UsageBreakdown,
13
16
  UsageProfileReport,
14
17
  UsageRecord,
15
18
  UsageShares,
16
19
  } from './usage.js';
20
+ export { conversationGrowth, createConversationTracker } from './conversation.js';
21
+ // Whether the cache TTL fits how fast the turns arrive — the mechanism behind a
22
+ // losing cache, readable only when the log carries a clock. See ttl-fit.ts.
23
+ export { TTL_1H_MS, TTL_5M_MS, cacheTtlFit, createTtlFitTracker } from './ttl-fit.js';
24
+ export type { CacheTtlFit, TtlFitOptions, TtlFitTracker, TtlFitVerdict } from './ttl-fit.js';
25
+ // Cache writes by conversations that never came back — a ceiling on waste,
26
+ // named as one, and a fact when the slice read nothing. See session-ledger.ts.
27
+ // The drivers of a change between two bills — one implementation, because the
28
+ // sign convention (positive means the bill grew) has flipped once already
29
+ // when restated by hand. See against.ts.
30
+ // The profile as a spreadsheet — one row per label and model, no total row,
31
+ // empty cells where dollars are unknown. See csv.ts.
32
+ export {
33
+ PROFILE_CSV_COLUMNS,
34
+ PROFILE_CSV_DAY_COLUMNS,
35
+ PROFILE_CSV_HOUR_COLUMNS,
36
+ profileToCsv,
37
+ } from './csv.js';
38
+ export type { ProfileCsvOptions, ProfileCsvShape } from './csv.js';
39
+ export { driversBetween } from './against.js';
40
+ export type { AgainstDriver } from './against.js';
41
+ // The same tokens at another model's rates — arithmetic, not advice, and it
42
+ // refuses to price a call the target could not have accepted. See reprice.ts.
43
+ // The shape of a call's input — the half of the bill a total could only name.
44
+ // See input-shape.ts.
45
+ export { createInputShapeTracker, inputShapes } from './input-shape.js';
46
+ export type { InputShape, InputShapeOptions, InputShapeTracker } from './input-shape.js';
47
+ // The same request sent again a moment later — a retry or a loop, named as
48
+ // the pattern it is and never as a certainty. See repeats.ts.
49
+ export { createRepeatsTracker, repeatedTurns } from './repeats.js';
50
+ export type { RepeatedTurns, RepeatsOptions, RepeatsTracker } from './repeats.js';
51
+ export { priceTokensOn, repriceProfile } from './reprice.js';
52
+ export type { OverContextSlice, RepriceReport, RepricedSlice } from './reprice.js';
53
+ export { createSessionLedgerTracker, singleTurnCacheWrites } from './session-ledger.js';
54
+ // What one conversation costs — median and p95, exact. See session-cost.ts.
55
+ export { createSessionCostTracker, sessionCostShapes } from './session-cost.js';
56
+ export type { SessionCostOptions, SessionCostShape, SessionCostTracker } from './session-cost.js';
57
+ export type {
58
+ SessionLedgerOptions,
59
+ SessionLedgerTracker,
60
+ SingleTurnCacheWrites,
61
+ } from './session-ledger.js';
62
+ export { createOutputShapeTracker, outputShapes } from './output-shape.js';
63
+ export type { OutputShape, OutputShapeOptions, OutputShapeTracker } from './output-shape.js';
64
+ export type { ConversationGrowth, ConversationOptions, ConversationTracker } from './conversation.js';
65
+ export { billLevers } from './levers.js';
66
+ export type { BillLevers, BillLeverOptions, LeverId, SliceLevers } from './levers.js';
17
67
  export { DETECTABLE_LANGUAGES, detectTextLanguage } from './language.js';
18
68
  export { countSentences, profilePrompt } from './profile.js';
19
69
  export { PHRASE_LANGUAGES } from './phrases.js';
@@ -240,4 +290,4 @@ export {
240
290
  budgetFor,
241
291
  parseConfig,
242
292
  } from './config-schema.js';
243
- export type { ResolvedBudget, TrazumConfig } from './config-schema.js';
293
+ export type { ResolvedBudget, SpendConfig, TrazumConfig } from './config-schema.js';
@@ -0,0 +1,259 @@
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
+ * How big a call's input actually is, and how uneven that is across a slice.
8
+ *
9
+ * ## The half of the bill nothing described
10
+ *
11
+ * `outputShapes` says where the *output* spend concentrates. Input had a total
12
+ * and nothing else — and on a RAG or agent workload input is most of the bill,
13
+ * made of retrieved context, conversation history and tool results that no
14
+ * prompt file contains. "Input is 63% of this bill" is true and unactionable;
15
+ * the question somebody can act on is whether that 63% is *every* call
16
+ * carrying a large prompt, or a few calls carrying an enormous one.
17
+ *
18
+ * Two slices with identical input spend want opposite responses:
19
+ *
20
+ * - **Even.** The p95 call carries roughly what the median call carries. The
21
+ * prompt is simply large, and the lever is the prompt: fewer retrieved
22
+ * documents, a shorter system block, caching if the prefix repeats.
23
+ * - **Skewed.** The p95 call carries twelve times the median. Something is
24
+ * growing — a conversation nobody truncates, a retrieval with no cap, a
25
+ * tool result pasted in whole. The median call is fine and the fix is a
26
+ * limit, not a rewrite.
27
+ *
28
+ * A total cannot tell those apart, and neither can the per-day series.
29
+ *
30
+ * ## What "input" means here
31
+ *
32
+ * Everything the model read: fresh input, cache reads and cache writes. That
33
+ * is the size of the request, which is what a context window and a retrieval
34
+ * cap are about. `cachedShare` then says how much of it was billed at the
35
+ * cache-read rate — a tenth of input on Anthropic — because a slice whose
36
+ * large calls are almost entirely cache reads is a very different bill from
37
+ * one paying full rate for the same tokens, and the token counts alone cannot
38
+ * tell them apart.
39
+ *
40
+ * ## Ceilings, never interpolations
41
+ *
42
+ * The counts live in fixed buckets, so a usage log measured in megabytes costs
43
+ * bounded memory. Every figure reported is a **bucket edge**: "half the calls
44
+ * fit within N tokens" is exact for the N named, where interpolating a median
45
+ * between two buckets would invent a call nobody made. `p95OverMedian` is
46
+ * therefore a ratio of two ceilings and coarse by construction — it is a shape,
47
+ * not a measurement, and the copy that renders it says which.
48
+ */
49
+
50
+ /** How one label-and-model slice's input is distributed across its calls. */
51
+ export interface InputShape {
52
+ label: string;
53
+ model: string;
54
+ modelName: string;
55
+ calls: number;
56
+ /** Fresh input, cache reads and cache writes — everything the model read. */
57
+ inputTokens: number;
58
+ /** What those tokens cost, at each class's own rate. */
59
+ inputUsd: number;
60
+ /** This slice's input spend as a fraction of the whole bill. */
61
+ shareOfBill: number;
62
+ /**
63
+ * The bucket ceiling at least half the calls fit within, and the same for
64
+ * 95% of them. `null` only when the covering bucket is the open-ended last
65
+ * one, which has no ceiling to name.
66
+ */
67
+ medianWithinTokens: number | null;
68
+ p95WithinTokens: number | null;
69
+ /**
70
+ * `p95WithinTokens / medianWithinTokens` — how much bigger the large calls
71
+ * are than the ordinary one. A ratio of two ceilings, so it is coarse on
72
+ * purpose; `null` when either ceiling is unknown or the median ceiling is
73
+ * zero.
74
+ */
75
+ p95OverMedian: number | null;
76
+ /**
77
+ * The share of these tokens that were cache reads.
78
+ *
79
+ * Says what the size actually costs: on Anthropic a cache read is a tenth of
80
+ * input, so a slice at 0.9 here is large and cheap, and one at 0 is large at
81
+ * full rate. Without it, "the p95 call carries 400,000 tokens" reads as an
82
+ * emergency in a workload that is caching correctly.
83
+ */
84
+ cachedShare: number;
85
+ }
86
+
87
+ export interface InputShapeOptions {
88
+ catalogue: PricingCatalogue;
89
+ on?: Date;
90
+ /** Slices whose input is below this share of the bill are dropped. Default 5%. */
91
+ minShare?: number;
92
+ /**
93
+ * Slices with fewer calls than this are dropped. Default 20.
94
+ *
95
+ * A p95 over four calls is the largest of the four wearing a percentile's
96
+ * name, and the sentence this feeds — "the large calls are twelve times the
97
+ * ordinary one" — would be a description of one call.
98
+ */
99
+ minCalls?: number;
100
+ }
101
+
102
+ /**
103
+ * Bucket edges sized for requests rather than answers.
104
+ *
105
+ * 512 tokens up to 65,536 is finer than any decision about a prompt, and past
106
+ * that the buckets widen to 8,192: the difference between a 400,000-token
107
+ * request and a 404,000-token one changes nothing. The last bucket is
108
+ * open-ended so a call larger than the widest edge still lands somewhere,
109
+ * counted rather than dropped.
110
+ */
111
+ const SMALL_STEP = 512;
112
+ const SMALL_LIMIT = 65_536;
113
+ const LARGE_STEP = 8_192;
114
+ const LARGE_LIMIT = 1_048_576;
115
+
116
+ const EDGES: number[] = (() => {
117
+ const edges: number[] = [];
118
+ for (let t = 0; t < SMALL_LIMIT; t += SMALL_STEP) edges.push(t);
119
+ for (let t = SMALL_LIMIT; t < LARGE_LIMIT; t += LARGE_STEP) edges.push(t);
120
+ return edges;
121
+ })();
122
+
123
+ const SMALL_BUCKETS = SMALL_LIMIT / SMALL_STEP;
124
+
125
+ /** Index of the bucket a count falls in. The last bucket is open-ended. */
126
+ function bucketOf(tokens: number): number {
127
+ if (tokens >= EDGES[EDGES.length - 1]!) return EDGES.length - 1;
128
+ if (tokens < SMALL_LIMIT) return Math.floor(tokens / SMALL_STEP);
129
+ return SMALL_BUCKETS + Math.floor((tokens - SMALL_LIMIT) / LARGE_STEP);
130
+ }
131
+
132
+ /** A bucket's upper edge, or `null` for the open-ended last one. */
133
+ function upperEdgeOf(bucket: number): number | null {
134
+ if (bucket >= EDGES.length - 1) return null;
135
+ return EDGES[bucket + 1]!;
136
+ }
137
+
138
+ /**
139
+ * The bucket ceiling covering `share` of the calls, walking up from the
140
+ * smallest requests. Exact over the histogram: every call at or below the
141
+ * returned ceiling is counted, none is interpolated.
142
+ */
143
+ function ceilingFor(buckets: Map<number, number>, totalCalls: number, share: number): number | null {
144
+ const ascending = [...buckets.keys()].sort((a, b) => a - b);
145
+ const target = totalCalls * share;
146
+ let covered = 0;
147
+ for (const b of ascending) {
148
+ covered += buckets.get(b)!;
149
+ if (covered >= target) return upperEdgeOf(b);
150
+ }
151
+ return upperEdgeOf(ascending[ascending.length - 1]!);
152
+ }
153
+
154
+ interface Slice {
155
+ calls: number;
156
+ inputTokens: number;
157
+ cachedTokens: number;
158
+ inputUsd: number;
159
+ /** Calls per bucket, sparse. */
160
+ buckets: Map<number, number>;
161
+ }
162
+
163
+ export interface InputShapeTracker {
164
+ add(record: UsageRecord): void;
165
+ finish(totalUsd: number): InputShape[];
166
+ }
167
+
168
+ /** An accumulator, fed in the pass a profile already makes. */
169
+ export function createInputShapeTracker(options: InputShapeOptions): InputShapeTracker {
170
+ const { catalogue, on = new Date(), minShare = 0.05, minCalls = 20 } = options;
171
+ const slices = new Map<string, Slice>();
172
+
173
+ const add = (record: UsageRecord): void => {
174
+ const model = catalogue.byId.get(record.model);
175
+ // An unpriced model contributes no dollars anywhere else; a shape drawn
176
+ // from one would describe a bill that was never computed.
177
+ if (!model) return;
178
+
179
+ const tokens =
180
+ record.inputTokens +
181
+ record.cacheReadTokens +
182
+ record.cacheWrite5mTokens +
183
+ record.cacheWrite1hTokens;
184
+ if (tokens <= 0) return;
185
+
186
+ const key = `${record.label ?? UNLABELLED}\n${record.model}`;
187
+ let slice = slices.get(key);
188
+ if (!slice) {
189
+ slice = { calls: 0, inputTokens: 0, cachedTokens: 0, inputUsd: 0, buckets: new Map() };
190
+ slices.set(key, slice);
191
+ }
192
+
193
+ const { inputPerMTok } = effectivePricing(model, on);
194
+ const rates = multipliersFor(model);
195
+ const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
196
+
197
+ slice.calls += 1;
198
+ slice.inputTokens += tokens;
199
+ slice.cachedTokens += record.cacheReadTokens;
200
+ slice.inputUsd +=
201
+ per(record.inputTokens, inputPerMTok) +
202
+ per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
203
+ per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
204
+ per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
205
+
206
+ const b = bucketOf(tokens);
207
+ slice.buckets.set(b, (slice.buckets.get(b) ?? 0) + 1);
208
+ };
209
+
210
+ const finish = (totalUsd: number): InputShape[] => {
211
+ const out: InputShape[] = [];
212
+
213
+ for (const [key, slice] of slices) {
214
+ const split = key.indexOf('\n');
215
+ const label = key.slice(0, split);
216
+ const modelId = key.slice(split + 1);
217
+ const model = catalogue.byId.get(modelId);
218
+ if (!model || slice.calls < minCalls) continue;
219
+
220
+ const shareOfBill = totalUsd > 0 ? slice.inputUsd / totalUsd : 0;
221
+ if (shareOfBill < minShare) continue;
222
+
223
+ const medianWithinTokens = ceilingFor(slice.buckets, slice.calls, 0.5);
224
+ const p95WithinTokens = ceilingFor(slice.buckets, slice.calls, 0.95);
225
+
226
+ out.push({
227
+ label,
228
+ model: modelId,
229
+ modelName: model.displayName,
230
+ calls: slice.calls,
231
+ inputTokens: slice.inputTokens,
232
+ inputUsd: slice.inputUsd,
233
+ shareOfBill,
234
+ medianWithinTokens,
235
+ p95WithinTokens,
236
+ p95OverMedian:
237
+ medianWithinTokens !== null && p95WithinTokens !== null && medianWithinTokens > 0
238
+ ? p95WithinTokens / medianWithinTokens
239
+ : null,
240
+ cachedShare: slice.inputTokens > 0 ? slice.cachedTokens / slice.inputTokens : 0,
241
+ });
242
+ }
243
+
244
+ return out.sort((a, b) => b.inputUsd - a.inputUsd);
245
+ };
246
+
247
+ return { add, finish };
248
+ }
249
+
250
+ /** The same measurement over a list of records, for a caller holding one. */
251
+ export function inputShapes(
252
+ records: readonly UsageRecord[],
253
+ totalUsd: number,
254
+ options: InputShapeOptions,
255
+ ): InputShape[] {
256
+ const tracker = createInputShapeTracker(options);
257
+ for (const record of records) tracker.add(record);
258
+ return tracker.finish(totalUsd);
259
+ }