@trazum/core 1.86.0 → 2.1.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.
@@ -314,48 +314,104 @@ export interface TrazumConfig {
314
314
  pricing?: string;
315
315
  }
316
316
 
317
+ /**
318
+ * A key list bound to the shape it describes.
319
+ *
320
+ * Every list below is what `rejectUnknownKeys` compares an incoming config
321
+ * against, and each one is a second copy of an interface declared elsewhere in
322
+ * this file. Nothing held them together. A plant added `telemetry?: boolean` to
323
+ * `TrazumConfig`, left `CONFIG_KEYS` alone, and **all 2,115 core tests passed**
324
+ * — leaving a documented setting the parser refuses by name, and the reverse
325
+ * plant leaving a key the parser accepts and nothing reads, which is the worse
326
+ * of the two because it is silent.
327
+ *
328
+ * `Record<keyof T, true>` closes both directions at compile time. The published
329
+ * arrays are derived from the maps rather than written again: key order is
330
+ * insertion order, so what a consumer reads is unchanged.
331
+ */
332
+ const keyList = <T>(map: Record<keyof T, true>): readonly (keyof T & string)[] =>
333
+ Object.keys(map) as (keyof T & string)[];
334
+
317
335
  /**
318
336
  * Every key the config accepts. Exported so the CLI's help can be tested
319
337
  * against it rather than against a second hand-maintained list — a setting the
320
338
  * help never mentions is one only the changelog knows about.
321
339
  */
322
- export const CONFIG_KEYS = [
323
- 'level',
324
- 'locale',
325
- 'disable',
326
- 'usage',
327
- 'budgets',
328
- 'labels',
329
- 'spend',
330
- 'limits',
331
- 'sources',
332
- 'store',
333
- 'waive',
334
- 'maxGrowth',
335
- 'baseline',
336
- 'extensions',
337
- 'ignore',
338
- 'pricing',
339
- 'outcomes',
340
- 'ladders',
341
- 'owners',
342
- ] as const;
343
-
344
- export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
345
-
346
- export const CONFIG_SPEND_KEYS = ['maxUsd', 'monthlyUsd', 'maxDayUsd', 'maxSessionUsd', 'maxCacheLossUsd', 'byLabel', 'bySource', 'substitute'] as const;
347
-
348
- export const CONFIG_LIMITS_KEYS = ['dayUsd', 'sessionUsd', 'byLabel'] as const;
349
-
350
- export const CONFIG_WAIVE_KEYS = ['gate', 'reason', 'until'] as const;
351
-
352
- export const CONFIG_STORE_KEYS = ['keepDays'] as const;
353
-
354
- export const CONFIG_OUTCOME_KEYS = ['values', 'success'] as const;
355
-
356
- export const CONFIG_LADDER_KEYS = ['tiers', 'escalateOn'] as const;
357
-
358
- export const CONFIG_OWNERS_KEYS = ['patterns', 'shared', 'budgets'] as const;
340
+ export const CONFIG_KEYS = keyList<TrazumConfig>({
341
+ level: true,
342
+ locale: true,
343
+ disable: true,
344
+ usage: true,
345
+ budgets: true,
346
+ labels: true,
347
+ spend: true,
348
+ limits: true,
349
+ sources: true,
350
+ store: true,
351
+ waive: true,
352
+ maxGrowth: true,
353
+ baseline: true,
354
+ extensions: true,
355
+ ignore: true,
356
+ pricing: true,
357
+ outcomes: true,
358
+ ladders: true,
359
+ owners: true,
360
+ });
361
+
362
+ export const CONFIG_BASELINE_KEYS = keyList<BaselineConfig>({
363
+ path: true,
364
+ maxGrowthTokens: true,
365
+ maxGrowthPct: true,
366
+ });
367
+
368
+ export const CONFIG_SPEND_KEYS = keyList<SpendConfig>({
369
+ maxUsd: true,
370
+ monthlyUsd: true,
371
+ maxDayUsd: true,
372
+ maxSessionUsd: true,
373
+ maxCacheLossUsd: true,
374
+ byLabel: true,
375
+ bySource: true,
376
+ substitute: true,
377
+ });
378
+
379
+ export const CONFIG_LIMITS_KEYS = keyList<LimitsConfig>({
380
+ dayUsd: true,
381
+ sessionUsd: true,
382
+ byLabel: true,
383
+ });
384
+
385
+ export const CONFIG_WAIVE_KEYS = keyList<WaiveEntry>({
386
+ gate: true,
387
+ reason: true,
388
+ until: true,
389
+ });
390
+
391
+ /*
392
+ `store` has no named interface, so the shape is reached through the field
393
+ itself. Naming a type purely to bind a list would put a third copy in the
394
+ file, which is the fault this is fixing.
395
+ */
396
+ export const CONFIG_STORE_KEYS = keyList<NonNullable<TrazumConfig['store']>>({
397
+ keepDays: true,
398
+ });
399
+
400
+ export const CONFIG_OUTCOME_KEYS = keyList<OutcomeVocabulary>({
401
+ values: true,
402
+ success: true,
403
+ });
404
+
405
+ export const CONFIG_LADDER_KEYS = keyList<LadderPolicy>({
406
+ tiers: true,
407
+ escalateOn: true,
408
+ });
409
+
410
+ export const CONFIG_OWNERS_KEYS = keyList<OwnersConfig>({
411
+ patterns: true,
412
+ shared: true,
413
+ budgets: true,
414
+ });
359
415
 
360
416
  /**
361
417
  * The gates a waiver can silence. The list is closed on purpose: a waiver
@@ -370,13 +426,13 @@ export const WAIVABLE_GATES = [
370
426
  'maxGrowthUsd',
371
427
  ] as const;
372
428
 
373
- export const CONFIG_USAGE_KEYS = [
374
- 'model',
375
- 'callsPerMonth',
376
- 'avgOutputTokens',
377
- 'cacheHitRate',
378
- 'batchEligible',
379
- ] as const;
429
+ export const CONFIG_USAGE_KEYS = keyList<UsageProfile>({
430
+ model: true,
431
+ callsPerMonth: true,
432
+ avgOutputTokens: true,
433
+ cacheHitRate: true,
434
+ batchEligible: true,
435
+ });
380
436
 
381
437
  const TOP_LEVEL_KEYS = CONFIG_KEYS;
382
438
  const USAGE_KEYS = CONFIG_USAGE_KEYS;
package/src/index.ts CHANGED
@@ -348,10 +348,16 @@ export type { RepeatedTurns, RepeatsOptions, RepeatsTracker } from './repeats.js
348
348
  // See context-pressure.ts.
349
349
  export { contextPressure } from './context-pressure.js';
350
350
  export type { ContextPressure, ContextPressureOptions } from './context-pressure.js';
351
- export { priceTokensOn, repriceProfile } from './reprice.js';
351
+ export { priceTokensOn, repriceProfile, repriceReceipt } from './reprice.js';
352
352
  export { ownRate, switchAnalysis } from './switch.js';
353
353
  export type { BreakEvenRefusal, SwitchAnalysis, SwitchOptions } from './switch.js';
354
- export type { OverContextSlice, RepriceReport, RepricedSlice } from './reprice.js';
354
+ export type {
355
+ OverContextSlice,
356
+ RepriceReport,
357
+ RepriceableInput,
358
+ RepriceableSlice,
359
+ RepricedSlice,
360
+ } from './reprice.js';
355
361
  export { createSessionLedgerTracker, singleTurnCacheWrites } from './session-ledger.js';
356
362
  // What one conversation costs — median and p95, exact. See session-cost.ts.
357
363
  export { createSessionCostTracker, sessionCostShapes } from './session-cost.js';
package/src/optimize.ts CHANGED
@@ -189,7 +189,22 @@ export function optimize(prompt: string, options: OptimizeOptions = {}): Optimiz
189
189
 
190
190
  const ruleResults: RuleResult[] = [...byRule.values()];
191
191
 
192
- const optimized = unmask(current, vault).trim();
192
+ /*
193
+ **Trimmed before the masks come off, not after.** `unmask(...).trim()` runs
194
+ on the reassembled string, where a protected span is ordinary text again —
195
+ so the trim could edit content every mask in this pipeline had just
196
+ promised to leave alone. A prompt whose first line is indented code lost
197
+ that indentation here, outside every guard, and the loss was invisible
198
+ because the trim is the last thing that happens.
199
+
200
+ It surfaced through idempotence rather than through a report: the block
201
+ stopped looking like a block, so the next pass no longer protected it and
202
+ the rules ate the code. The fuzzer found 54 of 1,500 corpus inputs that way.
203
+
204
+ Trimming the masked string cannot reach a protected span, because a
205
+ protected span is a placeholder at that point.
206
+ */
207
+ const optimized = unmask(current.trim(), vault);
193
208
  const tokensBefore = count(prompt);
194
209
  const tokensAfter = count(optimized);
195
210
 
package/src/phrases.ts CHANGED
@@ -248,6 +248,42 @@ export const FILLER: readonly string[] = [
248
248
  "it's important to note that",
249
249
  'it is worth noting that',
250
250
  "it's worth noting that",
251
+ /*
252
+ The five below were missing while the same constructions sat in other
253
+ languages' sections of this very list, which is how they were found: not by
254
+ taste, but by reading across the sections and asking what English lacked.
255
+
256
+ it should be noted that <- de: 'es sei darauf hingewiesen, dass'
257
+ it is worth mentioning that <- es: 'vale la pena mencionar que'
258
+ it is important to mention <- es: 'es importante mencionar que'
259
+
260
+ English carried four entries in this family and **two** constructions:
261
+ `it is important to note that` and `it is worth noting that`, each written
262
+ twice for the contraction. Spanish carried five constructions in five
263
+ entries. So the English section was not short of entries — it was spending
264
+ them on contractions of the same two phrasings while another language
265
+ covered five different ones.
266
+
267
+ That distinction matters and was got wrong once. The first version of this
268
+ comment said the English section was *the thinnest in the file*, which is
269
+ false: counted per language it is the longest, 12 entries against Spanish's
270
+ 12 and Italian's 9. The gap was inside one family, and generalising from it
271
+ to the whole list took a claim nobody had checked. The check is one script.
272
+
273
+ Measured on fifteen padded sentences an editor would trim without touching
274
+ an instruction, the rules recovered 34 of 76 tokens; these were among the
275
+ misses.
276
+
277
+ Nothing is added here for the other five languages. A phrasing they lack is
278
+ for whoever reads the language to judge — `docs/language-maintainer.md`
279
+ exists for exactly that, and guessing at French filler from an English ear
280
+ is how a rule starts editing prompts it does not understand.
281
+ */
282
+ 'it should be noted that',
283
+ 'it is worth mentioning that',
284
+ "it's worth mentioning that",
285
+ 'it is important to mention that',
286
+ "it's important to mention that",
251
287
  'needless to say',
252
288
  'in essence',
253
289
  'at the end of the day',
package/src/receipt.ts CHANGED
@@ -150,6 +150,34 @@ export interface ReceiptLine {
150
150
  money: ReceiptMoney;
151
151
  /** The catalogue's published rates, as provenance. */
152
152
  pricing: ReceiptPricing;
153
+ /**
154
+ * The largest single call in this slice, cache reads and writes included.
155
+ *
156
+ * **The one number that says whether these calls would fit somewhere else.**
157
+ * A cheaper model with a smaller context window does not make a 400k-token
158
+ * call cheaper, it makes it impossible, and counting an impossible call's
159
+ * price difference as a saving is the flattering direction this repository
160
+ * refuses. The maximum rather than an average, for the same reason: one call
161
+ * over the ceiling is a failed call, and a mean hides it.
162
+ *
163
+ * Added in 2.1.0, and the gap it closes is worth naming. The two cache-write
164
+ * TTL fields above have always been here *so a consumer could reprice this
165
+ * traffic against another model's rates* -- and `repriceProfile` refuses to
166
+ * price traffic that would not fit, which no reader of a receipt could
167
+ * honour, because the receipt did not carry this. The format was built for
168
+ * repricing and stopped one field short of it.
169
+ */
170
+ maxCallInputTokens: number;
171
+ /**
172
+ * Calls in this slice whose cache-write TTL the log did not state, so the
173
+ * cheaper 5-minute rate was assumed.
174
+ *
175
+ * The document already reports the organisation-wide figure as an
176
+ * `assumed-write-ttl` gap. This is the same fact per slice, which is the
177
+ * resolution a repricing needs: the assumption travels into the comparison,
178
+ * and a reader is entitled to know which slices carry it.
179
+ */
180
+ assumedWriteTtlCalls: number;
153
181
  }
154
182
 
155
183
  /** Everything a receipt refuses to answer, typed rather than left implicit. */
@@ -220,26 +248,50 @@ export interface ReceiptDocument {
220
248
  }
221
249
 
222
250
  /**
223
- * The fields that leave this machine, listed once, in one place.
251
+ * The fields that leave this machine, keyed by the interface they describe.
252
+ *
253
+ * `Record<keyof ReceiptLine, true>` is what holds the two together: a field
254
+ * added to `ReceiptLine` and not added here is a **compile error**, and so is
255
+ * an entry here that `ReceiptLine` does not have. A whitelist maintained in two
256
+ * places is a whitelist with a hole in it.
257
+ *
258
+ * **This used to say the redaction guard held that, and it did not.** The guard
259
+ * reads the emitted lines and checks every key it finds against this list,
260
+ * which catches an emitter that emits an unlisted field — a real property, kept
261
+ * below. It cannot catch a field added to the *type*: a plant added
262
+ * `operator?: string` to `ReceiptLine`, emitted it on no line the fixture
263
+ * built, and all twelve checks passed. A field populated in one branch is
264
+ * exactly the shape a leak takes, and the published whitelist customers read to
265
+ * see what leaves their machine would have been wrong while its own guard said
266
+ * otherwise.
267
+ */
268
+ const RECEIPT_LINE_FIELD_MAP = {
269
+ label: true,
270
+ model: true,
271
+ calls: true,
272
+ inputTokens: true,
273
+ cacheReadTokens: true,
274
+ cacheWriteTokens: true,
275
+ cacheWrite5mTokens: true,
276
+ cacheWrite1hTokens: true,
277
+ outputTokens: true,
278
+ usd: true,
279
+ money: true,
280
+ pricing: true,
281
+ maxCallInputTokens: true,
282
+ assumedWriteTtlCalls: true,
283
+ } satisfies Record<keyof ReceiptLine, true>;
284
+
285
+ /**
286
+ * The same list, published.
224
287
  *
225
- * The redaction guard reads this array rather than a copy of it, so a field
226
- * added to `ReceiptLine` and not added here fails the guard. A whitelist
227
- * maintained in two places is a whitelist with a hole in it.
288
+ * Derived from the map rather than written again, because writing it twice is
289
+ * the fault this exists to prevent. Key order is insertion order, so the array
290
+ * a consumer reads is unchanged.
228
291
  */
229
- export const RECEIPT_LINE_FIELDS = Object.freeze([
230
- 'label',
231
- 'model',
232
- 'calls',
233
- 'inputTokens',
234
- 'cacheReadTokens',
235
- 'cacheWriteTokens',
236
- 'cacheWrite5mTokens',
237
- 'cacheWrite1hTokens',
238
- 'outputTokens',
239
- 'usd',
240
- 'money',
241
- 'pricing',
242
- ] as const);
292
+ export const RECEIPT_LINE_FIELDS: readonly string[] = Object.freeze(
293
+ Object.keys(RECEIPT_LINE_FIELD_MAP),
294
+ );
243
295
 
244
296
  const lineFrom = (
245
297
  label: string,
@@ -282,6 +334,11 @@ const lineFrom = (
282
334
  outputPerMTok: priced.outputPerMTok,
283
335
  reviewedOn: PROVIDER_REVIEWED[priced.provider ?? ''] ?? null,
284
336
  },
337
+ /* Copied, never derived. Neither can be recovered from the aggregates
338
+ above: a maximum is not a mean, and how many calls were silent about
339
+ their TTL is not visible in a token total. */
340
+ maxCallInputTokens: breakdown.maxCallInputTokens,
341
+ assumedWriteTtlCalls: breakdown.assumedWriteTtlCalls,
285
342
  };
286
343
  };
287
344
 
package/src/reprice.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { effectivePricing, multipliersFor } from './pricing.js';
2
2
  import type { PricingCatalogue } from './pricing.js';
3
3
  import type { ModelPricing } from './types.js';
4
+ import type { ReceiptDocument } from './receipt.js';
4
5
  import type { UsageBreakdown, UsageProfileReport } from './usage.js';
6
+ import { UNLABELLED } from './usage.js';
5
7
 
6
8
  /**
7
9
  * The same tokens at another model's rates.
@@ -195,11 +197,50 @@ export function priceTokensOn(
195
197
  * against a price nobody has is worse than no comparison, and the caller is
196
198
  * better placed to say so in its own words than this is to invent a zero.
197
199
  */
198
- export function repriceProfile(
199
- report: UsageProfileReport,
200
+ /**
201
+ * The least a slice has to carry to be repriced.
202
+ *
203
+ * Narrower than `UsageBreakdown` on purpose, and that narrowing is what lets a
204
+ * **receipt** be repriced at all. A receipt is the aggregate that leaves a
205
+ * machine; it does not carry a breakdown's truncation counts or its cache
206
+ * economics, and it never will, because those are answers to questions a
207
+ * receipt does not exist to answer. What it does carry — since 2.1.0 — is
208
+ * exactly this.
209
+ *
210
+ * Two entry points feed it and one implementation consumes it. Two copies of
211
+ * this loop, one reading profiles and one reading receipts, is the defect this
212
+ * codebase has found more times than any other, and it would be worse here
213
+ * than usual: the copies would drift on which traffic is refused, and a
214
+ * refusal that quietly stops happening reads as a saving.
215
+ */
216
+ export interface RepriceableSlice {
217
+ /** `UNLABELLED` for the unlabelled bucket, never `null`. */
218
+ label: string;
219
+ model: string;
220
+ calls: number;
221
+ inputTokens: number;
222
+ cacheReadTokens: number;
223
+ cacheWrite5mTokens: number;
224
+ cacheWrite1hTokens: number;
225
+ outputTokens: number;
226
+ /** What it actually cost, as apportioned rather than as recomputed. */
227
+ totalUsd: number;
228
+ maxCallInputTokens: number;
229
+ assumedWriteTtlCalls: number;
230
+ }
231
+
232
+ /** The slices, plus what could not be priced at all. */
233
+ export interface RepriceableInput {
234
+ slices: readonly RepriceableSlice[];
235
+ unpricedModels: readonly string[];
236
+ unpricedCalls: number;
237
+ }
238
+
239
+ function repriceSlices(
240
+ input: RepriceableInput,
200
241
  targetId: string,
201
242
  catalogue: PricingCatalogue,
202
- on: Date = new Date(),
243
+ on: Date,
203
244
  ): RepriceReport | null {
204
245
  const target = catalogue.byId.get(targetId);
205
246
  if (!target) return null;
@@ -209,17 +250,16 @@ export function repriceProfile(
209
250
  const alreadyOnTarget = { calls: 0, usd: 0 };
210
251
  let assumedWriteTtlCalls = 0;
211
252
 
212
- for (const slice of report.byLabelAndModel) {
213
- const { breakdown } = slice;
214
- if (slice.model === target.id) {
253
+ for (const breakdown of input.slices) {
254
+ if (breakdown.model === target.id) {
215
255
  alreadyOnTarget.calls += breakdown.calls;
216
256
  alreadyOnTarget.usd += breakdown.totalUsd;
217
257
  continue;
218
258
  }
219
259
  if (breakdown.maxCallInputTokens > target.contextWindow) {
220
260
  overContext.push({
221
- label: slice.label,
222
- model: slice.model,
261
+ label: breakdown.label,
262
+ model: breakdown.model,
223
263
  calls: breakdown.calls,
224
264
  currentUsd: breakdown.totalUsd,
225
265
  maxCallInputTokens: breakdown.maxCallInputTokens,
@@ -261,8 +301,8 @@ export function repriceProfile(
261
301
  }
262
302
  : null;
263
303
  slices.push({
264
- label: slice.label,
265
- model: slice.model,
304
+ label: breakdown.label,
305
+ model: breakdown.model,
266
306
  calls: breakdown.calls,
267
307
  currentUsd: breakdown.totalUsd,
268
308
  targetUsd,
@@ -287,15 +327,14 @@ export function repriceProfile(
287
327
  let batchOnTarget: { targetUsd: number } | null = null;
288
328
  if (batchRate !== null && batchRate < 1 && slices.length > 0) {
289
329
  const { inputPerMTok, outputPerMTok } = effectivePricing(target, on);
290
- const movable = report.byLabelAndModel.filter(
291
- (slice) =>
292
- slice.model !== target.id && slice.breakdown.maxCallInputTokens <= target.contextWindow,
330
+ const movable = input.slices.filter(
331
+ (slice) => slice.model !== target.id && slice.maxCallInputTokens <= target.contextWindow,
293
332
  );
294
333
  const inOutUsd = movable.reduce(
295
334
  (sum, slice) =>
296
335
  sum +
297
- (slice.breakdown.inputTokens / 1_000_000) * inputPerMTok +
298
- (slice.breakdown.outputTokens / 1_000_000) * outputPerMTok,
336
+ (slice.inputTokens / 1_000_000) * inputPerMTok +
337
+ (slice.outputTokens / 1_000_000) * outputPerMTok,
299
338
  0,
300
339
  );
301
340
  batchOnTarget = { targetUsd: targetUsd - inOutUsd * (1 - batchRate) };
@@ -310,9 +349,117 @@ export function repriceProfile(
310
349
  overContext,
311
350
  alreadyOnTarget,
312
351
  assumedWriteTtlCalls,
313
- unpricedModels: report.unpricedModels,
314
- unpricedCalls: report.unpriced.calls,
352
+ /* Copied rather than passed through. The input's array belongs to the
353
+ caller -- a profile's own field, or a receipt's gap -- and handing it out
354
+ inside a report is how a reader ends up able to edit the document they
355
+ were given. */
356
+ unpricedModels: [...input.unpricedModels],
357
+ unpricedCalls: input.unpricedCalls,
315
358
  batchOnTarget,
316
359
  sameTokensAssumed: true,
317
360
  };
318
361
  }
362
+
363
+
364
+ /**
365
+ * Reprices a profile's label-and-model slices onto one target model.
366
+ *
367
+ * Returns `null` when the catalogue does not know the target: a comparison
368
+ * against a price nobody has is worse than no comparison, and the caller is
369
+ * better placed to say so in its own words than this is to invent a zero.
370
+ */
371
+ export function repriceProfile(
372
+ report: UsageProfileReport,
373
+ targetId: string,
374
+ catalogue: PricingCatalogue,
375
+ on: Date = new Date(),
376
+ ): RepriceReport | null {
377
+ return repriceSlices(
378
+ {
379
+ slices: report.byLabelAndModel.map((slice) => ({
380
+ label: slice.label,
381
+ model: slice.model,
382
+ calls: slice.breakdown.calls,
383
+ inputTokens: slice.breakdown.inputTokens,
384
+ cacheReadTokens: slice.breakdown.cacheReadTokens,
385
+ cacheWrite5mTokens: slice.breakdown.cacheWrite5mTokens,
386
+ cacheWrite1hTokens: slice.breakdown.cacheWrite1hTokens,
387
+ outputTokens: slice.breakdown.outputTokens,
388
+ totalUsd: slice.breakdown.totalUsd,
389
+ maxCallInputTokens: slice.breakdown.maxCallInputTokens,
390
+ assumedWriteTtlCalls: slice.breakdown.assumedWriteTtlCalls,
391
+ })),
392
+ unpricedModels: report.unpricedModels,
393
+ unpricedCalls: report.unpriced.calls,
394
+ },
395
+ targetId,
396
+ catalogue,
397
+ on,
398
+ );
399
+ }
400
+
401
+ /**
402
+ * The same question, asked of a **receipt** rather than of the log behind it.
403
+ *
404
+ * ## Why this exists
405
+ *
406
+ * A receipt is what leaves a machine, and by the time anybody wants to ask
407
+ * *what would these calls have cost on the small model* the log is usually
408
+ * gone — deleted by a retention policy, on a runner that no longer exists, or
409
+ * simply on somebody else's laptop. A comparison that can only be made where
410
+ * the log still is can only be made by the person who least needs it.
411
+ *
412
+ * It also settles a question the receipt format had left open. The two
413
+ * cache-write TTL fields say in their own comment that they exist *so a
414
+ * consumer can reprice this traffic*, and until 2.1.0 no consumer could: the
415
+ * refusal that protects the whole comparison — never price traffic the target
416
+ * could not have accepted — needs the largest call, and the receipt did not
417
+ * carry it. This is the entry point that field was added for.
418
+ *
419
+ * ## What it refuses, which is the same four things
420
+ *
421
+ * Nothing here is a second implementation. It maps a receipt's lines onto the
422
+ * same slices `repriceProfile` maps a profile onto, and the one loop that
423
+ * refuses over-context traffic, excludes calls already on the target, keeps
424
+ * the write TTLs apart and states `sameTokensAssumed` is the loop both use.
425
+ *
426
+ * The unlabelled bucket travels as `UNLABELLED` rather than `null`, which is
427
+ * the spelling every other consumer of these types already reads.
428
+ */
429
+ export function repriceReceipt(
430
+ document: ReceiptDocument,
431
+ targetId: string,
432
+ catalogue: PricingCatalogue,
433
+ on: Date = new Date(),
434
+ ): RepriceReport | null {
435
+ /*
436
+ * What the catalogue could not price is in the document's gaps rather than
437
+ * in a line, which is the receipt's own arrangement: an unpriced call has no
438
+ * rate, so it has no line to be in. Read from there rather than left at
439
+ * zero, so a comparison covering half a bill cannot look complete.
440
+ */
441
+ const unpriced = document.gaps.find((gap) => gap.kind === 'unpriced');
442
+
443
+ return repriceSlices(
444
+ {
445
+ slices: document.lines.map((line) => ({
446
+ label: line.label ?? UNLABELLED,
447
+ model: line.model,
448
+ calls: line.calls,
449
+ inputTokens: line.inputTokens,
450
+ cacheReadTokens: line.cacheReadTokens,
451
+ cacheWrite5mTokens: line.cacheWrite5mTokens,
452
+ cacheWrite1hTokens: line.cacheWrite1hTokens,
453
+ outputTokens: line.outputTokens,
454
+ totalUsd: line.usd,
455
+ maxCallInputTokens: line.maxCallInputTokens,
456
+ assumedWriteTtlCalls: line.assumedWriteTtlCalls,
457
+ })),
458
+ unpricedModels: unpriced === undefined ? [] : unpriced.models,
459
+ unpricedCalls: unpriced === undefined ? 0 : unpriced.calls,
460
+ },
461
+ targetId,
462
+ catalogue,
463
+ on,
464
+ );
465
+ }