@trazum/core 2.4.2 → 2.5.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 (121) hide show
  1. package/dist/advisories.d.ts.map +1 -1
  2. package/dist/advisories.js +17 -10
  3. package/dist/advisories.js.map +1 -1
  4. package/dist/answer.d.ts.map +1 -1
  5. package/dist/answer.js +2 -1
  6. package/dist/answer.js.map +1 -1
  7. package/dist/anthropic-usage.d.ts +6 -0
  8. package/dist/anthropic-usage.d.ts.map +1 -1
  9. package/dist/anthropic-usage.js +1 -0
  10. package/dist/anthropic-usage.js.map +1 -1
  11. package/dist/billing.d.ts +20 -0
  12. package/dist/billing.d.ts.map +1 -0
  13. package/dist/billing.js +32 -0
  14. package/dist/billing.js.map +1 -0
  15. package/dist/budget.d.ts.map +1 -1
  16. package/dist/budget.js +18 -8
  17. package/dist/budget.js.map +1 -1
  18. package/dist/connector.d.ts +20 -1
  19. package/dist/connector.d.ts.map +1 -1
  20. package/dist/connector.js +42 -11
  21. package/dist/connector.js.map +1 -1
  22. package/dist/conversation.d.ts.map +1 -1
  23. package/dist/conversation.js +4 -2
  24. package/dist/conversation.js.map +1 -1
  25. package/dist/gateway.d.ts.map +1 -1
  26. package/dist/gateway.js +5 -3
  27. package/dist/gateway.js.map +1 -1
  28. package/dist/guard.d.ts.map +1 -1
  29. package/dist/guard.js +7 -5
  30. package/dist/guard.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/input-shape.d.ts.map +1 -1
  34. package/dist/input-shape.js +4 -2
  35. package/dist/input-shape.js.map +1 -1
  36. package/dist/ladder.d.ts.map +1 -1
  37. package/dist/ladder.js +2 -1
  38. package/dist/ladder.js.map +1 -1
  39. package/dist/levers.d.ts.map +1 -1
  40. package/dist/levers.js +5 -2
  41. package/dist/levers.js.map +1 -1
  42. package/dist/openai-usage.d.ts +18 -0
  43. package/dist/openai-usage.d.ts.map +1 -1
  44. package/dist/openai-usage.js +26 -1
  45. package/dist/openai-usage.js.map +1 -1
  46. package/dist/openrouter-activity.d.ts +6 -0
  47. package/dist/openrouter-activity.d.ts.map +1 -1
  48. package/dist/openrouter-activity.js +7 -0
  49. package/dist/openrouter-activity.js.map +1 -1
  50. package/dist/output-shape.d.ts.map +1 -1
  51. package/dist/output-shape.js +2 -1
  52. package/dist/output-shape.js.map +1 -1
  53. package/dist/pricing.d.ts +36 -4
  54. package/dist/pricing.d.ts.map +1 -1
  55. package/dist/pricing.js +804 -91
  56. package/dist/pricing.js.map +1 -1
  57. package/dist/receipt.d.ts +17 -0
  58. package/dist/receipt.d.ts.map +1 -1
  59. package/dist/receipt.js +10 -0
  60. package/dist/receipt.js.map +1 -1
  61. package/dist/repeats.d.ts.map +1 -1
  62. package/dist/repeats.js +4 -2
  63. package/dist/repeats.js.map +1 -1
  64. package/dist/reprice.d.ts +18 -2
  65. package/dist/reprice.d.ts.map +1 -1
  66. package/dist/reprice.js +34 -12
  67. package/dist/reprice.js.map +1 -1
  68. package/dist/rollup.d.ts.map +1 -1
  69. package/dist/rollup.js +3 -0
  70. package/dist/rollup.js.map +1 -1
  71. package/dist/savings.d.ts.map +1 -1
  72. package/dist/savings.js +10 -3
  73. package/dist/savings.js.map +1 -1
  74. package/dist/session-cost.d.ts.map +1 -1
  75. package/dist/session-cost.js +5 -2
  76. package/dist/session-cost.js.map +1 -1
  77. package/dist/session-ledger.d.ts.map +1 -1
  78. package/dist/session-ledger.js +12 -7
  79. package/dist/session-ledger.js.map +1 -1
  80. package/dist/truncation-retry.d.ts.map +1 -1
  81. package/dist/truncation-retry.js +4 -2
  82. package/dist/truncation-retry.js.map +1 -1
  83. package/dist/ttl-fit.d.ts +8 -0
  84. package/dist/ttl-fit.d.ts.map +1 -1
  85. package/dist/ttl-fit.js +25 -14
  86. package/dist/ttl-fit.js.map +1 -1
  87. package/dist/types.d.ts +51 -0
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/usage.d.ts +36 -1
  90. package/dist/usage.d.ts.map +1 -1
  91. package/dist/usage.js +91 -10
  92. package/dist/usage.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/advisories.ts +18 -10
  95. package/src/answer.ts +6 -1
  96. package/src/anthropic-usage.ts +7 -0
  97. package/src/billing.ts +37 -0
  98. package/src/budget.ts +18 -8
  99. package/src/connector.ts +58 -10
  100. package/src/conversation.ts +4 -2
  101. package/src/gateway.ts +5 -3
  102. package/src/guard.ts +7 -5
  103. package/src/index.ts +1 -1
  104. package/src/input-shape.ts +4 -2
  105. package/src/ladder.ts +2 -1
  106. package/src/levers.ts +5 -2
  107. package/src/openai-usage.ts +44 -2
  108. package/src/openrouter-activity.ts +13 -0
  109. package/src/output-shape.ts +2 -1
  110. package/src/pricing.ts +854 -96
  111. package/src/receipt.ts +23 -1
  112. package/src/repeats.ts +4 -2
  113. package/src/reprice.ts +57 -16
  114. package/src/rollup.ts +3 -0
  115. package/src/savings.ts +12 -5
  116. package/src/session-cost.ts +5 -2
  117. package/src/session-ledger.ts +18 -9
  118. package/src/truncation-retry.ts +4 -2
  119. package/src/ttl-fit.ts +39 -15
  120. package/src/types.ts +46 -0
  121. package/src/usage.ts +129 -11
package/src/receipt.ts CHANGED
@@ -210,7 +210,18 @@ export type ReceiptGap =
210
210
  * and a document that says `counting: 'counted'` while quietly resting on an
211
211
  * assumption is claiming a precision it does not have.
212
212
  */
213
- | { kind: 'assumed-write-ttl'; calls: number };
213
+ | { kind: 'assumed-write-ttl'; calls: number }
214
+ /**
215
+ * Rows billed at the dearer of two rates because which one applied could
216
+ * not be decided: a model priced by prompt length, and a row that sums
217
+ * requests without saying any one prompt's length.
218
+ *
219
+ * Reported because it changes what kind of figure this is, in the other
220
+ * direction from `assumed-write-ttl`: the total is a **ceiling** on these
221
+ * rows. `ceilingUsd` is their share of it, `floorUsd` the same tokens at the
222
+ * cheaper rate; the true figure for them is between the two.
223
+ */
224
+ | { kind: 'tier-ceiling'; calls: number; models: string[]; ceilingUsd: number; floorUsd: number };
214
225
 
215
226
  export interface ReceiptDocument {
216
227
  /** The one thing a consumer must branch on. Set here, by the builder. */
@@ -389,6 +400,17 @@ export function receiptFrom(
389
400
  );
390
401
  if (assumed > 0) gaps.push({ kind: 'assumed-write-ttl', calls: assumed });
391
402
 
403
+ const ceilinged = report.byModel.filter((row) => row.breakdown.tierCeilingCalls > 0);
404
+ if (ceilinged.length > 0) {
405
+ gaps.push({
406
+ kind: 'tier-ceiling',
407
+ calls: report.total.tierCeilingCalls,
408
+ models: ceilinged.map((row) => row.model),
409
+ ceilingUsd: report.total.tierCeilingUsd,
410
+ floorUsd: report.total.tierFloorUsd,
411
+ });
412
+ }
413
+
392
414
  return {
393
415
  schemaVersion: 1,
394
416
  emittedAt: options.emittedAt?.toISOString() ?? null,
package/src/repeats.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { billedAt, promptSizeOf } from './billing.js';
2
3
  import { UNLABELLED } from './usage.js';
3
4
  import type { PricingCatalogue } from './pricing.js';
4
5
  import type { UsageRecord } from './usage.js';
@@ -119,8 +120,9 @@ export function createRepeatsTracker(options: RepeatsOptions): RepeatsTracker {
119
120
  if (gap < 0 || gap >= withinMs) return;
120
121
  if (before.tokens !== tokens) return;
121
122
 
122
- const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
123
- const rates = multipliersFor(model);
123
+ const at = billedAt(record, on);
124
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, at, promptSizeOf(record));
125
+ const rates = multipliersFor(model, at);
124
126
  const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
125
127
  slice.repeats += 1;
126
128
  slice.usd +=
package/src/reprice.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { effectivePricing, multipliersFor } from './pricing.js';
2
- import type { PricingCatalogue } from './pricing.js';
2
+ import type { PricingCatalogue, PromptSize } from './pricing.js';
3
3
  import type { ModelPricing } from './types.js';
4
4
  import type { ReceiptDocument } from './receipt.js';
5
5
  import type { UsageBreakdown, UsageProfileReport } from './usage.js';
@@ -158,6 +158,19 @@ export interface RepriceReport {
158
158
  * is the reader's.
159
159
  */
160
160
  batchOnTarget: { targetUsd: number } | null;
161
+ /**
162
+ * Slices priced at the target's dearer band because which band applied
163
+ * could not be decided, and what `targetUsd` would be with them at the
164
+ * cheaper one. `null` when every slice was decided.
165
+ *
166
+ * A target priced by prompt length bills each request on its own prompt; a
167
+ * slice is a sum, and its largest call is all it says about any one prompt.
168
+ * A slice whose largest call is under the line is under it throughout, and
169
+ * decided. One whose largest call is over is not, and is priced at the
170
+ * dearer band, so the comparison is a ceiling and never a saving it cannot
171
+ * stand behind; this is the other end.
172
+ */
173
+ tierCeiling: { slices: number; targetFloorUsd: number } | null;
161
174
  /** Always true. A field, not a comment, so a rendering can print it. */
162
175
  sameTokensAssumed: true;
163
176
  }
@@ -177,17 +190,35 @@ export function priceTokensOn(
177
190
  >,
178
191
  model: ModelPricing,
179
192
  on: Date = new Date(),
193
+ size: PromptSize = {},
180
194
  ): number {
181
- const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
182
- const rates = multipliersFor(model);
195
+ return priceTokensRanged(breakdown, model, on, size).usd;
196
+ }
197
+
198
+ /** The same price, and the cheaper one when a size band could not be decided. */
199
+ function priceTokensRanged(
200
+ breakdown: Pick<
201
+ UsageBreakdown,
202
+ 'inputTokens' | 'cacheReadTokens' | 'cacheWrite5mTokens' | 'cacheWrite1hTokens' | 'outputTokens'
203
+ >,
204
+ model: ModelPricing,
205
+ on: Date,
206
+ size: PromptSize,
207
+ ): { usd: number; floorUsd: number | null } {
208
+ const priced = effectivePricing(model, on, size);
209
+ const rates = multipliersFor(model, on);
183
210
  const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
184
- return (
211
+ const at = (inputPerMTok: number, outputPerMTok: number): number =>
185
212
  per(breakdown.inputTokens, inputPerMTok) +
186
213
  per(breakdown.cacheReadTokens, inputPerMTok * rates.cacheRead) +
187
214
  per(breakdown.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
188
215
  per(breakdown.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
189
- per(breakdown.outputTokens, outputPerMTok)
190
- );
216
+ per(breakdown.outputTokens, outputPerMTok);
217
+ const floor = priced.tier?.decided === false ? priced.tier.floor : undefined;
218
+ return {
219
+ usd: at(priced.inputPerMTok, priced.outputPerMTok),
220
+ floorUsd: floor === undefined ? null : at(floor.inputPerMTok, floor.outputPerMTok),
221
+ };
191
222
  }
192
223
 
193
224
  /**
@@ -249,6 +280,8 @@ function repriceSlices(
249
280
  const overContext: OverContextSlice[] = [];
250
281
  const alreadyOnTarget = { calls: 0, usd: 0 };
251
282
  let assumedWriteTtlCalls = 0;
283
+ let ceilingSlices = 0;
284
+ let floorSaving = 0;
252
285
 
253
286
  for (const breakdown of input.slices) {
254
287
  if (breakdown.model === target.id) {
@@ -266,7 +299,14 @@ function repriceSlices(
266
299
  });
267
300
  continue;
268
301
  }
269
- const targetUsd = priceTokensOn(breakdown, target, on);
302
+ /* The slice's largest call bounds every prompt in it. */
303
+ const bound: PromptSize = { inputTokensAtMost: breakdown.maxCallInputTokens };
304
+ const ranged = priceTokensRanged(breakdown, target, on, bound);
305
+ const targetUsd = ranged.usd;
306
+ if (ranged.floorUsd !== null) {
307
+ ceilingSlices += 1;
308
+ floorSaving += ranged.usd - ranged.floorUsd;
309
+ }
270
310
  assumedWriteTtlCalls += breakdown.assumedWriteTtlCalls;
271
311
  /**
272
312
  * Cache traffic the target could not grant. The comparison is against the
@@ -297,6 +337,7 @@ function repriceSlices(
297
337
  },
298
338
  target,
299
339
  on,
340
+ bound,
300
341
  ),
301
342
  }
302
343
  : null;
@@ -323,20 +364,19 @@ function repriceSlices(
323
364
  * aggregates rather than by discounting `targetUsd`, because the discount
324
365
  * covers input and output while cache traffic rides through unchanged.
325
366
  */
326
- const batchRate = multipliersFor(target).batch;
367
+ const batchRate = multipliersFor(target, on).batch;
327
368
  let batchOnTarget: { targetUsd: number } | null = null;
328
369
  if (batchRate !== null && batchRate < 1 && slices.length > 0) {
329
- const { inputPerMTok, outputPerMTok } = effectivePricing(target, on);
330
370
  const movable = input.slices.filter(
331
371
  (slice) => slice.model !== target.id && slice.maxCallInputTokens <= target.contextWindow,
332
372
  );
333
- const inOutUsd = movable.reduce(
334
- (sum, slice) =>
335
- sum +
336
- (slice.inputTokens / 1_000_000) * inputPerMTok +
337
- (slice.outputTokens / 1_000_000) * outputPerMTok,
338
- 0,
339
- );
373
+ // Each slice at the band its own pricing above used, so the two cannot disagree.
374
+ const inOutUsd = movable.reduce((sum, slice) => {
375
+ const { inputPerMTok, outputPerMTok } = effectivePricing(target, on, {
376
+ inputTokensAtMost: slice.maxCallInputTokens,
377
+ });
378
+ return sum + (slice.inputTokens / 1_000_000) * inputPerMTok + (slice.outputTokens / 1_000_000) * outputPerMTok;
379
+ }, 0);
340
380
  batchOnTarget = { targetUsd: targetUsd - inOutUsd * (1 - batchRate) };
341
381
  }
342
382
 
@@ -356,6 +396,7 @@ function repriceSlices(
356
396
  unpricedModels: [...input.unpricedModels],
357
397
  unpricedCalls: input.unpricedCalls,
358
398
  batchOnTarget,
399
+ tierCeiling: ceilingSlices === 0 ? null : { slices: ceilingSlices, targetFloorUsd: targetUsd - floorSaving },
359
400
  sameTokensAssumed: true,
360
401
  };
361
402
  }
package/src/rollup.ts CHANGED
@@ -316,6 +316,9 @@ const BREAKDOWN_SUM = [
316
316
  'truncatedCalls',
317
317
  'truncatedOutputUsd',
318
318
  'stopReasonCalls',
319
+ 'tierCeilingCalls',
320
+ 'tierCeilingUsd',
321
+ 'tierFloorUsd',
319
322
  ] as const;
320
323
 
321
324
  /** Fields whose combination is a maximum, because a sum would invent a call. */
package/src/savings.ts CHANGED
@@ -53,7 +53,14 @@ export function computeSavings(
53
53
  pricing: PricingCatalogue = BUNDLED_CATALOGUE,
54
54
  ): SavingsReport {
55
55
  const model = modelFrom(pricing, usage.model);
56
- const { inputPerMTok, outputPerMTok, promoApplied } = effectivePricing(model, on);
56
+ /*
57
+ * Each prompt at its own length. On a model priced by prompt length the two
58
+ * sides can sit in different bands, and an edit that brings a prompt under
59
+ * the line saves the whole difference in rate, not only the tokens cut.
60
+ */
61
+ const ratesBefore = effectivePricing(model, on, { inputTokens: tokensBefore });
62
+ const ratesAfter = effectivePricing(model, on, { inputTokens: tokensAfter });
63
+ const promoApplied = ratesBefore.promoApplied || ratesAfter.promoApplied;
57
64
  // A provider with no batch API gets no discount even when the caller ticked
58
65
  // the box: `batchEligible` describes the work, not what the provider sells.
59
66
  const batchDiscount = multipliersFor(model).batch ?? 1;
@@ -61,16 +68,16 @@ export function computeSavings(
61
68
  const before = costOfCall(
62
69
  tokensBefore,
63
70
  usage.avgOutputTokens,
64
- inputPerMTok,
65
- outputPerMTok,
71
+ ratesBefore.inputPerMTok,
72
+ ratesBefore.outputPerMTok,
66
73
  usage.batchEligible,
67
74
  batchDiscount,
68
75
  );
69
76
  const after = costOfCall(
70
77
  tokensAfter,
71
78
  usage.avgOutputTokens,
72
- inputPerMTok,
73
- outputPerMTok,
79
+ ratesAfter.inputPerMTok,
80
+ ratesAfter.outputPerMTok,
74
81
  usage.batchEligible,
75
82
  batchDiscount,
76
83
  );
@@ -1,4 +1,5 @@
1
1
  import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { billedAt, promptSizeOf } from './billing.js';
2
3
  import { UNLABELLED } from './usage.js';
3
4
  import type { PricingCatalogue } from './pricing.js';
4
5
  import type { UsageRecord } from './usage.js';
@@ -75,8 +76,10 @@ export interface SessionCostTracker {
75
76
  export function costOf(record: UsageRecord, catalogue: PricingCatalogue, on: Date): number | null {
76
77
  const model = catalogue.byId.get(record.model);
77
78
  if (!model) return null;
78
- const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
79
- const rates = multipliersFor(model);
79
+ // At the call's own moment and on its own prompt, as the bill prices it.
80
+ const at = billedAt(record, on);
81
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, at, promptSizeOf(record));
82
+ const rates = multipliersFor(model, at);
80
83
  const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
81
84
  return (
82
85
  per(record.inputTokens, inputPerMTok) +
@@ -1,4 +1,5 @@
1
1
  import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { billedAt, promptSizeOf } from './billing.js';
2
3
  import { UNLABELLED } from './usage.js';
3
4
  import type { PricingCatalogue } from './pricing.js';
4
5
  import type { UsageRecord } from './usage.js';
@@ -77,6 +78,12 @@ interface SessionTally {
77
78
  write5mTokens: number;
78
79
  write1hTokens: number;
79
80
  assumedTokens: number;
81
+ /**
82
+ * What this conversation's writes cost, each call at its own moment and on
83
+ * its own prompt, so a one-turn conversation is priced exactly as the bill
84
+ * priced its one call.
85
+ */
86
+ writeUsd: number;
80
87
  }
81
88
 
82
89
  const median = (sorted: number[]): number => {
@@ -107,10 +114,18 @@ export function createSessionLedgerTracker(options: SessionLedgerOptions): Sessi
107
114
  }
108
115
  let tally = sessions.get(record.session);
109
116
  if (!tally) {
110
- tally = { turns: 0, write5mTokens: 0, write1hTokens: 0, assumedTokens: 0 };
117
+ tally = { turns: 0, write5mTokens: 0, write1hTokens: 0, assumedTokens: 0, writeUsd: 0 };
111
118
  sessions.set(record.session, tally);
112
119
  }
113
120
  tally.turns += 1;
121
+ const model = catalogue.byId.get(record.model)!;
122
+ const at = billedAt(record, on);
123
+ const { inputPerMTok } = effectivePricing(model, at, promptSizeOf(record));
124
+ const rates = multipliersFor(model, at);
125
+ // The bill's own convention: unstated TTLs sit in the 5m bucket at the cheaper rate.
126
+ tally.writeUsd +=
127
+ ((record.cacheWrite5mTokens * rates.cacheWrite5m + record.cacheWrite1hTokens * rates.cacheWrite1h) / 1_000_000)
128
+ * inputPerMTok;
114
129
  if (record.writeTtlKnown) {
115
130
  tally.write5mTokens += record.cacheWrite5mTokens;
116
131
  tally.write1hTokens += record.cacheWrite1hTokens;
@@ -129,6 +144,7 @@ export function createSessionLedgerTracker(options: SessionLedgerOptions): Sessi
129
144
  let write5m = 0;
130
145
  let write1h = 0;
131
146
  let assumed = 0;
147
+ let singleTurnWriteUsd = 0;
132
148
  const turnCounts: number[] = [];
133
149
  for (const tally of sessions.values()) {
134
150
  turnCounts.push(tally.turns);
@@ -137,6 +153,7 @@ export function createSessionLedgerTracker(options: SessionLedgerOptions): Sessi
137
153
  write5m += tally.write5mTokens;
138
154
  write1h += tally.write1hTokens;
139
155
  assumed += tally.assumedTokens;
156
+ singleTurnWriteUsd += tally.writeUsd;
140
157
  }
141
158
  const singleTurnWriteTokens = write5m + write1h + assumed;
142
159
  // One-turn conversations that wrote nothing wasted nothing; a row about
@@ -146,14 +163,6 @@ export function createSessionLedgerTracker(options: SessionLedgerOptions): Sessi
146
163
  const split = key.indexOf('\n');
147
164
  const modelId = key.slice(split + 1);
148
165
  const model = catalogue.byId.get(modelId)!;
149
- const { inputPerMTok } = effectivePricing(model, on);
150
- const rates = multipliersFor(model);
151
- const per = (tokens: number, rate: number): number =>
152
- (tokens / 1_000_000) * inputPerMTok * rate;
153
- // The bill's own convention: unstated TTLs at the cheaper rate, so this
154
- // is the same floor the totals already stand on — never a new guess.
155
- const singleTurnWriteUsd =
156
- per(write5m + assumed, rates.cacheWrite5m) + per(write1h, rates.cacheWrite1h);
157
166
 
158
167
  turnCounts.sort((a, b) => a - b);
159
168
  out.push({
@@ -1,4 +1,5 @@
1
1
  import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { billedAt, promptSizeOf } from './billing.js';
2
3
  import { UNLABELLED } from './usage.js';
3
4
  import type { PricingCatalogue } from './pricing.js';
4
5
  import type { UsageRecord } from './usage.js';
@@ -90,8 +91,9 @@ export function createTruncationRetryTracker(options: TruncationRetryOptions): T
90
91
  const priceOf = (record: UsageRecord): number | null => {
91
92
  const model = catalogue.byId.get(record.model);
92
93
  if (!model) return null;
93
- const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
94
- const rates = multipliersFor(model);
94
+ const at = billedAt(record, on);
95
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, at, promptSizeOf(record));
96
+ const rates = multipliersFor(model, at);
95
97
  const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
96
98
  return (
97
99
  per(record.inputTokens, inputPerMTok) +
package/src/ttl-fit.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { effectivePricing, multipliersFor } from './pricing.js';
1
+ import { cacheTtlOf, effectivePricing, multipliersFor } from './pricing.js';
2
+ import { billedAt, promptSizeOf } from './billing.js';
2
3
  import { UNLABELLED } from './usage.js';
3
4
  import type { PricingCatalogue } from './pricing.js';
4
5
  import type { UsageRecord } from './usage.js';
@@ -79,6 +80,14 @@ export interface CacheTtlFit {
79
80
  /** Write tokens whose TTL the log did not record. */
80
81
  assumedTtlTokens: number;
81
82
  verdict: TtlFitVerdict;
83
+ /**
84
+ * How long this model's entries live: the one a write buys, and the longer
85
+ * one the provider also sells, or `null` when it sells none. Five minutes
86
+ * and an hour on Anthropic; thirty minutes and nothing longer on OpenAI's
87
+ * GPT-5.6 and later. A verdict reads them, and so does the sentence about it.
88
+ */
89
+ entryTtlMs: number;
90
+ longerTtlMs: number | null;
82
91
  /**
83
92
  * What the 1-hour writes would save at the 5-minute rate, when the gaps show
84
93
  * the hour is never needed. Exact — the same tokens at 1.25x instead of 2x,
@@ -104,6 +113,12 @@ interface Slice {
104
113
  write5mTokens: number;
105
114
  write1hTokens: number;
106
115
  assumedTtlTokens: number;
116
+ /**
117
+ * What the 1-hour writes would have saved at the 5-minute rate, each call at
118
+ * its own moment and on its own prompt: one rate for the whole slice priced
119
+ * a call on another band, or in another price period, at the wrong figure.
120
+ */
121
+ overlongUsd: number;
107
122
  /** Timestamps per session, sorted only at the end. */
108
123
  sessions: Map<string, number[]>;
109
124
  }
@@ -138,13 +153,21 @@ export function createTtlFitTracker(options: TtlFitOptions): TtlFitTracker {
138
153
  const key = `${record.label ?? UNLABELLED}\n${record.model}`;
139
154
  let slice = slices.get(key);
140
155
  if (!slice) {
141
- slice = { write5mTokens: 0, write1hTokens: 0, assumedTtlTokens: 0, sessions: new Map() };
156
+ slice = { write5mTokens: 0, write1hTokens: 0, assumedTtlTokens: 0, overlongUsd: 0, sessions: new Map() };
142
157
  slices.set(key, slice);
143
158
  }
144
159
 
145
160
  if (record.writeTtlKnown) {
146
161
  slice.write5mTokens += record.cacheWrite5mTokens;
147
162
  slice.write1hTokens += record.cacheWrite1hTokens;
163
+ if (record.cacheWrite1hTokens > 0) {
164
+ const model = catalogue.byId.get(record.model)!;
165
+ const at = billedAt(record, on);
166
+ const { inputPerMTok } = effectivePricing(model, at, promptSizeOf(record));
167
+ const rates = multipliersFor(model, at);
168
+ slice.overlongUsd +=
169
+ (record.cacheWrite1hTokens / 1_000_000) * inputPerMTok * (rates.cacheWrite1h - rates.cacheWrite5m);
170
+ }
148
171
  } else {
149
172
  // The flat count sits in the 5m bucket by pricing convention; here it is
150
173
  // kept apart, because the whole question is which TTL it really was.
@@ -192,22 +215,21 @@ export function createTtlFitTracker(options: TtlFitOptions): TtlFitTracker {
192
215
  * uncertain one and money comes last: writes that expire are broken at
193
216
  * any price, and only writes that demonstrably work can be overpaying.
194
217
  */
218
+ /*
219
+ * Each model's own entry lives, not Anthropic's: an OpenAI entry outlives
220
+ * a ten-minute pause that a five-minute one would not.
221
+ */
222
+ const ttl = cacheTtlOf(model);
223
+ const longer = ttl.longerMs ?? ttl.writeMs;
195
224
  let verdict: TtlFitVerdict;
196
- if (slice.write5mTokens > 0 && gap > TTL_5M_MS) verdict = 'expires-before-reuse';
197
- else if (slice.write1hTokens > 0 && gap > TTL_1H_MS) verdict = 'expires-before-reuse';
198
- else if (slice.assumedTtlTokens > 0 && gap > TTL_1H_MS) verdict = 'expires-before-reuse';
199
- else if (slice.write1hTokens > 0 && gap <= TTL_5M_MS) verdict = 'overlong-ttl';
200
- else if (slice.assumedTtlTokens > 0 && gap > TTL_5M_MS) verdict = 'unsettled';
225
+ if (slice.write5mTokens > 0 && gap > ttl.writeMs) verdict = 'expires-before-reuse';
226
+ else if (slice.write1hTokens > 0 && gap > longer) verdict = 'expires-before-reuse';
227
+ else if (slice.assumedTtlTokens > 0 && gap > longer) verdict = 'expires-before-reuse';
228
+ else if (slice.write1hTokens > 0 && gap <= ttl.writeMs) verdict = 'overlong-ttl';
229
+ else if (slice.assumedTtlTokens > 0 && gap > ttl.writeMs) verdict = 'unsettled';
201
230
  else verdict = 'fits';
202
231
 
203
- const { inputPerMTok } = effectivePricing(model, on);
204
- const rates = multipliersFor(model);
205
- const overpayUsd =
206
- verdict === 'overlong-ttl'
207
- ? (slice.write1hTokens / 1_000_000) *
208
- inputPerMTok *
209
- (rates.cacheWrite1h - rates.cacheWrite5m)
210
- : 0;
232
+ const overpayUsd = verdict === 'overlong-ttl' ? slice.overlongUsd : 0;
211
233
 
212
234
  out.push({
213
235
  label: key.slice(0, split),
@@ -220,6 +242,8 @@ export function createTtlFitTracker(options: TtlFitOptions): TtlFitTracker {
220
242
  write1hTokens: slice.write1hTokens,
221
243
  assumedTtlTokens: slice.assumedTtlTokens,
222
244
  verdict,
245
+ entryTtlMs: ttl.writeMs,
246
+ longerTtlMs: ttl.longerMs,
223
247
  overpayUsd,
224
248
  });
225
249
  }
package/src/types.ts CHANGED
@@ -169,6 +169,12 @@ export type TierCondition =
169
169
  * `effectivePricing` says which happened rather than guessing.
170
170
  */
171
171
  | { kind: 'input-tokens-above'; tokens: number }
172
+ /**
173
+ * The prompt is `tokens` input tokens or more: xAI's wording, "requests
174
+ * whose prompt reaches 200k tokens". The same as above at one token less,
175
+ * kept apart so the sentence a reader sees is the provider's own.
176
+ */
177
+ | { kind: 'input-tokens-at-least'; tokens: number }
172
178
  /**
173
179
  * The call falls inside one of `hours` in UTC, optionally on weekdays only.
174
180
  *
@@ -203,6 +209,18 @@ export interface PricingTier {
203
209
  outputPerMTok: number;
204
210
  }
205
211
 
212
+ /** One published price this model carried until an instant. See `ModelPricing.history`. */
213
+ export interface PricePeriod {
214
+ /** ISO 8601 instant, exclusive: a call at exactly this instant is priced by what came after. */
215
+ until: string;
216
+ inputPerMTok: number;
217
+ outputPerMTok: number;
218
+ /** The conditional rates in force during the period; none means none applied. */
219
+ tiers?: PricingTier[];
220
+ /** The cache and batch ratios during the period; absent means the row's own. */
221
+ multipliers?: CostMultipliers;
222
+ }
223
+
206
224
  export interface ModelPricing {
207
225
  id: string;
208
226
  displayName: string;
@@ -226,6 +244,18 @@ export interface ModelPricing {
226
244
  caching?: CachingMode;
227
245
  /** Provider-specific cache and batch pricing. Defaults to Anthropic's. */
228
246
  multipliers?: CostMultipliers;
247
+ /**
248
+ * How long a cache entry lives, for a provider that bills writes.
249
+ *
250
+ * `writeMs` is the life of the entry a `cacheWrite5m` write buys, and
251
+ * `longerMs` the longer entry the provider also sells, or `null` when it
252
+ * sells none. Absent means Anthropic's: five minutes, and an hour at 2x.
253
+ * OpenAI's GPT-5.6 and later write one kind of entry, which lives at least
254
+ * 30 minutes after its last write or read, and the TTL verdict judged it
255
+ * against five minutes until this field existed: a conversation pausing for
256
+ * ten minutes was told its cache expired before it was read.
257
+ */
258
+ cacheTtl?: { writeMs: number; longerMs: number | null };
229
259
  /**
230
260
  * Whether to offer this model as a cheaper alternative.
231
261
  *
@@ -298,6 +328,22 @@ export interface ModelPricing {
298
328
  * and keeps this field optional in effect as well as in the type.
299
329
  */
300
330
  tiers?: PricingTier[];
331
+ /**
332
+ * What this id cost before its current price, earliest period first.
333
+ *
334
+ * A usage log is history, and a provider that cuts a price at 04:00 UTC on
335
+ * a Thursday has billed the same id two ways in one month. One price for
336
+ * all time priced the older calls at the newer rate, or the newer at the
337
+ * older, and nothing on screen said which. A call at `on` is priced by the
338
+ * first period whose `until` is after it; a call at or after the last
339
+ * `until` takes the fields above. Each period carries its own tiers and
340
+ * cache ratio, because a provider that reprices usually reprices both.
341
+ *
342
+ * Only periods a provider published, with the instant it published. A
343
+ * period before the first one this table can source is not guessed at: the
344
+ * earliest period stands for it, and the row's note says so.
345
+ */
346
+ history?: PricePeriod[];
301
347
  /**
302
348
  * Other ids that bill at exactly this rate.
303
349
  *