@trazum/core 1.82.0 → 1.85.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 (58) hide show
  1. package/README.md +2 -2
  2. package/dist/advisories.d.ts.map +1 -1
  3. package/dist/advisories.js +27 -4
  4. package/dist/advisories.js.map +1 -1
  5. package/dist/band.d.ts.map +1 -1
  6. package/dist/band.js +12 -0
  7. package/dist/band.js.map +1 -1
  8. package/dist/i18n/en.d.ts.map +1 -1
  9. package/dist/i18n/en.js +7 -0
  10. package/dist/i18n/en.js.map +1 -1
  11. package/dist/i18n/es.d.ts.map +1 -1
  12. package/dist/i18n/es.js +8 -0
  13. package/dist/i18n/es.js.map +1 -1
  14. package/dist/i18n/types.d.ts +17 -0
  15. package/dist/i18n/types.d.ts.map +1 -1
  16. package/dist/index.d.ts +6 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/langsmith.d.ts +104 -0
  21. package/dist/langsmith.d.ts.map +1 -0
  22. package/dist/langsmith.js +270 -0
  23. package/dist/langsmith.js.map +1 -0
  24. package/dist/levers.d.ts +1 -1
  25. package/dist/levers.js +2 -2
  26. package/dist/levers.js.map +1 -1
  27. package/dist/optimize.d.ts +2 -2
  28. package/dist/optimize.d.ts.map +1 -1
  29. package/dist/optimize.js +15 -1
  30. package/dist/optimize.js.map +1 -1
  31. package/dist/pricing.d.ts +98 -3
  32. package/dist/pricing.d.ts.map +1 -1
  33. package/dist/pricing.js +393 -35
  34. package/dist/pricing.js.map +1 -1
  35. package/dist/receipt.d.ts +253 -0
  36. package/dist/receipt.d.ts.map +1 -0
  37. package/dist/receipt.js +111 -0
  38. package/dist/receipt.js.map +1 -0
  39. package/dist/tokenizer.d.ts +4 -2
  40. package/dist/tokenizer.d.ts.map +1 -1
  41. package/dist/tokenizer.js +4 -2
  42. package/dist/tokenizer.js.map +1 -1
  43. package/dist/types.d.ts +148 -1
  44. package/dist/types.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/advisories.ts +34 -4
  47. package/src/band.ts +12 -0
  48. package/src/i18n/en.ts +9 -0
  49. package/src/i18n/es.ts +10 -0
  50. package/src/i18n/types.ts +18 -0
  51. package/src/index.ts +14 -1
  52. package/src/langsmith.ts +315 -0
  53. package/src/levers.ts +3 -3
  54. package/src/optimize.ts +16 -0
  55. package/src/pricing.ts +430 -30
  56. package/src/receipt.ts +351 -0
  57. package/src/tokenizer.ts +4 -2
  58. package/src/types.ts +144 -0
package/src/pricing.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ModelPricing } from './types.js';
1
+ import type { ModelPricing, PricingTier, TierCondition } from './types.js';
2
2
 
3
3
  /**
4
4
  * Model and pricing catalogue (USD per million tokens).
@@ -8,7 +8,57 @@ import type { ModelPricing } from './types.js';
8
8
  * Amazon Bedrock and Vertex AI pricing is set by each partner and is NOT the
9
9
  * pricing in this table.
10
10
  */
11
- export const PRICING_LAST_REVIEWED = '2026-06-24';
11
+ /**
12
+ * When each provider's prices were last checked against that provider's own
13
+ * published table.
14
+ *
15
+ * One date for the whole catalogue was the wrong shape, and it cost a real
16
+ * error. Seven providers publish independently and are checked independently;
17
+ * a single date forces a partial review to either overstate itself by moving
18
+ * the date for everyone, or throw itself away by leaving it. What happened is
19
+ * the second: this constant was written on 2026-08-04 already reading
20
+ * 2026-06-24, and never moved again.
21
+ *
22
+ * Meanwhile Anthropic cancelled the increase that was going to take Sonnet 5
23
+ * from its 2/10 introductory price to 3/15 on 2026-09-01, and made 2/10
24
+ * standard. The catalogue still carried the promotion with its end date, so on
25
+ * 2026-09-01 every Sonnet 5 figure this tool printed would have risen 50% with
26
+ * no code change and no way for a reader to tell. A price nobody charges is
27
+ * the one error this product cannot make, and it was four days out on a timer.
28
+ *
29
+ * So: a date per provider, checked against that provider's own page, and the
30
+ * catalogue's headline date derived as the oldest of them rather than typed.
31
+ */
32
+ export const PROVIDER_REVIEWED: Readonly<Record<string, string>> = Object.freeze({
33
+ /* platform.claude.com/docs/en/about-claude/pricing, read 2026-08-27. */
34
+ anthropic: '2026-08-27',
35
+ openai: '2026-06-24',
36
+ /* ai.google.dev/gemini-api/docs/pricing, read 2026-08-28. */
37
+ google: '2026-08-28',
38
+ moonshot: '2026-06-24',
39
+ /* api-docs.deepseek.com/quick_start/pricing, read 2026-08-28. */
40
+ deepseek: '2026-08-28',
41
+ xai: '2026-06-24',
42
+ /*
43
+ * `mistral.ai/pricing`, read 2026-08-28 — by a human, because the page
44
+ * renders its table in the browser and serves a fetch only an FAQ example
45
+ * with no model id attached.
46
+ *
47
+ * This date stayed at 2026-06-24 for a few hours after the other two moved,
48
+ * which is the point of having one per provider: looking and finding nothing
49
+ * servable is not the same as checking, and moving the date then would have
50
+ * said it was.
51
+ */
52
+ mistral: '2026-08-28',
53
+ });
54
+
55
+ /**
56
+ * The catalogue's age is its oldest provider's, because a reader asking how
57
+ * old this table is wants the answer for the worst part of it, not the best.
58
+ */
59
+ export const PRICING_LAST_REVIEWED: string = Object.values(PROVIDER_REVIEWED).reduce(
60
+ (oldest, date) => (date < oldest ? date : oldest),
61
+ );
12
62
 
13
63
  /** Cost multipliers relative to the input price. */
14
64
  /**
@@ -138,14 +188,15 @@ export const MODELS: ModelPricing[] = [
138
188
  id: 'claude-sonnet-5',
139
189
  provider: 'anthropic',
140
190
  displayName: 'Claude Sonnet 5',
141
- inputPerMTok: 3,
142
- outputPerMTok: 15,
191
+ inputPerMTok: 2,
192
+ outputPerMTok: 10,
143
193
  contextWindow: 1_000_000,
144
194
  cacheMinTokens: 1024,
145
195
  capability: 'mid',
146
196
  tier: 'sonnet',
147
- promo: { inputPerMTok: 2, outputPerMTok: 10, until: '2026-08-31' },
148
- notes: 'Introductory pricing of 2/10 until 2026-08-31; 3/15 afterwards.',
197
+ notes:
198
+ 'Launched with 2/10 as introductory pricing through 2026-08-31; the scheduled'
199
+ + ' increase to 3/15 on 2026-09-01 was cancelled and 2/10 is the standard price.',
149
200
  },
150
201
  {
151
202
  id: 'claude-sonnet-4-6',
@@ -237,6 +288,12 @@ export const MODELS: ModelPricing[] = [
237
288
  cacheMinTokens: 2048,
238
289
  caching: 'explicit',
239
290
  multipliers: { cacheRead: 0.25, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
291
+ /* Refused on 2026-08-28: still in `GET /v1beta/models`, and a 404 to a request. */
292
+ retired: {
293
+ on: '2026-08-28',
294
+ because:
295
+ 'This model models/gemini-2.5-pro is no longer available to new users. Please update your code to use models/gemini-3.1-pro-preview for the latest features and improvements. We recommend you to use the Interactions API.',
296
+ },
240
297
  capability: 'large',
241
298
  tier: 'opus',
242
299
  notes: 'Context caching is billed for storage per hour as well as per read.',
@@ -251,10 +308,50 @@ export const MODELS: ModelPricing[] = [
251
308
  cacheMinTokens: 1024,
252
309
  caching: 'explicit',
253
310
  multipliers: { cacheRead: 0.25, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
311
+ /* Refused on 2026-08-28: still in `GET /v1beta/models`, and a 404 to a request. */
312
+ retired: {
313
+ on: '2026-08-28',
314
+ because:
315
+ 'This model models/gemini-2.5-flash is no longer available to new users. Please update your code to use models/gemini-3.6-flash for the latest features and improvements. We recommend you to use the Interactions API.',
316
+ },
254
317
  capability: 'mid',
255
318
  tier: 'sonnet',
256
319
  },
257
320
 
321
+ {
322
+ /*
323
+ * Google's own error message names this as the replacement for the two
324
+ * retired 2.5 models, and ai.google.dev/gemini-api/docs/pricing carries the
325
+ * rates, read 2026-08-28.
326
+ *
327
+ * The introductory price has a published end date and a published successor,
328
+ * which is exactly what `promo` is for and is the shape the catalogue nearly
329
+ * shipped Sonnet 5 wrong on: on 2027-01-01 every figure here doubles with no
330
+ * code change, and `pricing-review.test.js` fails before that arrives.
331
+ */
332
+ id: 'gemini-3.6-flash',
333
+ provider: 'google',
334
+ displayName: 'Gemini 3.6 Flash',
335
+ inputPerMTok: 1.5,
336
+ outputPerMTok: 7.5,
337
+ promo: { inputPerMTok: 0.75, outputPerMTok: 3.75, until: '2026-12-31' },
338
+ contextWindow: 1_048_576,
339
+ cacheMinTokens: 1024,
340
+ caching: 'explicit',
341
+ /*
342
+ * $0.075 against $0.75 inside the introductory window, and $0.15 against
343
+ * $1.50 after it: Google scales the cache price with the headline one, so
344
+ * a single ratio is right on both sides of 2027-01-01 and the multiplier
345
+ * does not need a date of its own.
346
+ */
347
+ multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
348
+ capability: 'mid',
349
+ tier: 'sonnet',
350
+ notes:
351
+ 'Context caching is billed for storage per hour as well as per read:'
352
+ + ' $0.50 per 1M tokens per hour through 2026-12-31, $1.00 after.',
353
+ },
354
+
258
355
  // ------------------------------------------------------------------------
259
356
  // Moonshot
260
357
  // ------------------------------------------------------------------------
@@ -286,9 +383,70 @@ export const MODELS: ModelPricing[] = [
286
383
  cacheMinTokens: 1024,
287
384
  caching: 'automatic',
288
385
  multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: null },
386
+ /* Refused on 2026-08-28: the id itself is rejected, and the error names the survivors. */
387
+ retired: {
388
+ on: '2026-08-28',
389
+ because:
390
+ 'The supported API model names are deepseek-v4-pro, deepseek-v4-flash, and deepseek-v4-flash-vision-exp, but you passed deepseek-v3.',
391
+ },
289
392
  capability: 'mid',
290
393
  tier: 'sonnet',
291
394
  },
395
+ {
396
+ /*
397
+ * DeepSeek V4 Flash, from api-docs.deepseek.com/quick_start/pricing, read
398
+ * 2026-08-28. It replaced `deepseek-v3`, which the API now refuses.
399
+ *
400
+ * The base rate is off-peak because off-peak is the common case: peak is
401
+ * 01:00-04:00 and 06:00-10:00 UTC on weekdays, which is 35 hours of 168.
402
+ * The peak tier is exactly double, published as such.
403
+ */
404
+ id: 'deepseek-v4-flash',
405
+ provider: 'deepseek',
406
+ displayName: 'DeepSeek V4 Flash',
407
+ inputPerMTok: 0.22,
408
+ outputPerMTok: 0.66,
409
+ tiers: [
410
+ {
411
+ id: 'peak',
412
+ when: { kind: 'utc-hours', hours: [1, 2, 3, 6, 7, 8, 9], weekdaysOnly: true },
413
+ inputPerMTok: 0.44,
414
+ outputPerMTok: 1.32,
415
+ },
416
+ ],
417
+ contextWindow: 1_000_000,
418
+ cacheMinTokens: 1024,
419
+ caching: 'automatic',
420
+ /* $0.007 cache hit against $0.22 input off-peak. */
421
+ multipliers: { cacheRead: 0.0318, cacheWrite5m: 1, cacheWrite1h: 1, batch: null },
422
+ capability: 'mid',
423
+ tier: 'sonnet',
424
+ notes: 'Maximum output 384K tokens.',
425
+ },
426
+ {
427
+ /* Same page, same clock. Pro is three times Flash at both ends. */
428
+ id: 'deepseek-v4-pro',
429
+ provider: 'deepseek',
430
+ displayName: 'DeepSeek V4 Pro',
431
+ inputPerMTok: 0.66,
432
+ outputPerMTok: 1.98,
433
+ tiers: [
434
+ {
435
+ id: 'peak',
436
+ when: { kind: 'utc-hours', hours: [1, 2, 3, 6, 7, 8, 9], weekdaysOnly: true },
437
+ inputPerMTok: 1.32,
438
+ outputPerMTok: 3.96,
439
+ },
440
+ ],
441
+ contextWindow: 1_000_000,
442
+ cacheMinTokens: 1024,
443
+ caching: 'automatic',
444
+ /* $0.022 cache hit against $0.66 input off-peak. */
445
+ multipliers: { cacheRead: 0.0333, cacheWrite5m: 1, cacheWrite1h: 1, batch: null },
446
+ capability: 'large',
447
+ tier: 'opus',
448
+ notes: 'Maximum output 384K tokens.',
449
+ },
292
450
 
293
451
  // ------------------------------------------------------------------------
294
452
  // xAI
@@ -320,10 +478,89 @@ export const MODELS: ModelPricing[] = [
320
478
  cacheMinTokens: 0,
321
479
  caching: 'none',
322
480
  multipliers: { batch: 0.5 },
481
+ /* Refused on 2026-08-28: the id itself is rejected. */
482
+ retired: {
483
+ on: '2026-08-28',
484
+ because:
485
+ 'Invalid model: mistral-large-2',
486
+ },
487
+ capability: 'large',
488
+ tier: 'opus',
489
+ notes: 'No prompt caching: reordering still helps readability but saves nothing here.',
490
+ },
491
+ {
492
+ /*
493
+ * Mistral Large 3, the replacement for the refused `mistral-large-2`.
494
+ *
495
+ * **Read by a human from `mistral.ai/pricing` on 2026-08-28, and that is the
496
+ * provenance.** The page renders its table in the browser, so a fetch sees
497
+ * only an FAQ example with no model id attached: this repository looked,
498
+ * found nothing servable, left the model unpriced and said so, and the table
499
+ * was then read off the page and handed over. A price is a reading of the
500
+ * provider's page; who did the reading is a fact about the reading, not a
501
+ * reason to accept a worse source.
502
+ *
503
+ * The identity is measured rather than inferred. `GET /v1/models` declares
504
+ * `mistral-large-latest` and `mistral-large-2512` as aliases of each other,
505
+ * both reporting the name `mistral-large-2512` and a 262,144 context, so the
506
+ * dated id is what a usage log carries and the moving pointer is the alias.
507
+ */
508
+ id: 'mistral-large-2512',
509
+ aliases: ['mistral-large-latest'],
510
+ provider: 'mistral',
511
+ displayName: 'Mistral Large 3',
512
+ inputPerMTok: 0.5,
513
+ outputPerMTok: 1.5,
514
+ contextWindow: 262_144,
515
+ cacheMinTokens: 0,
516
+ caching: 'none',
517
+ multipliers: { batch: 0.5 },
323
518
  capability: 'large',
324
519
  tier: 'opus',
325
520
  notes: 'No prompt caching: reordering still helps readability but saves nothing here.',
326
521
  },
522
+ {
523
+ /*
524
+ * Same page, same day, same reading.
525
+ *
526
+ * **This provider's ladder is not monotonic in price, and that is not a
527
+ * typo.** Medium 3.5 costs three times Large 3 on input and five times on
528
+ * output, because Mistral positions Large as the open-weight flagship and
529
+ * Medium as the model for long-horizon agentic work. `capability` follows
530
+ * the vendor's own description rather than the price, which is what the
531
+ * field means; the consequence is that a step down from `large` to `mid`
532
+ * here is dearer, and `levers.ts` simply does not offer a route whose saving
533
+ * is not above zero. Nothing to fix, and worth saying so before somebody
534
+ * "corrects" the ladder into producing a saving that does not exist.
535
+ */
536
+ id: 'mistral-medium-2604',
537
+ aliases: ['mistral-medium-latest', 'mistral-medium-3-5'],
538
+ provider: 'mistral',
539
+ displayName: 'Mistral Medium 3.5',
540
+ inputPerMTok: 1.5,
541
+ outputPerMTok: 7.5,
542
+ contextWindow: 262_144,
543
+ cacheMinTokens: 0,
544
+ caching: 'none',
545
+ multipliers: { batch: 0.5 },
546
+ capability: 'mid',
547
+ tier: 'sonnet',
548
+ },
549
+ {
550
+ /* Same page, same day. `mistral-small-2603` is what the API reports. */
551
+ id: 'mistral-small-2603',
552
+ aliases: ['mistral-small-latest'],
553
+ provider: 'mistral',
554
+ displayName: 'Mistral Small 4',
555
+ inputPerMTok: 0.15,
556
+ outputPerMTok: 0.6,
557
+ contextWindow: 262_144,
558
+ cacheMinTokens: 0,
559
+ caching: 'none',
560
+ multipliers: { batch: 0.5 },
561
+ capability: 'small',
562
+ tier: 'haiku',
563
+ },
327
564
  ];
328
565
 
329
566
  export const DEFAULT_MODEL = 'claude-opus-5';
@@ -390,6 +627,28 @@ function makeCatalogue(
390
627
  /** The prices compiled into this release. */
391
628
  export const BUNDLED_CATALOGUE: PricingCatalogue = makeCatalogue(MODELS, PRICING_LAST_REVIEWED);
392
629
 
630
+ /**
631
+ * Whether this model may be offered to a reader as something to move to.
632
+ *
633
+ * One home for a two-part rule that used to be written in four places as one
634
+ * part. `recommendable: false` — a private programme, a waitlist — was honoured
635
+ * at every one of them. `retired` is the stronger statement: the provider
636
+ * answered a real request for the id with an error, so a switch to it is not a
637
+ * worse recommendation, it is one that cannot be carried out at all.
638
+ *
639
+ * Written as a function rather than as a second `&&` at four call sites,
640
+ * because the fifth call site is always the one that gets written with only the
641
+ * first half. `pricing.test.js` fails a source file that filters on
642
+ * `recommendable` without coming through here, and that guard was proved by
643
+ * planting exactly that filter.
644
+ *
645
+ * Pricing is a separate question and stays answered: a retired model keeps its
646
+ * price, because a log full of its calls records money that was really spent.
647
+ */
648
+ export function isOffered(model: ModelPricing): boolean {
649
+ return model.recommendable !== false && model.retired === undefined;
650
+ }
651
+
393
652
  /** Looks a model up in a specific catalogue. */
394
653
  export function modelFrom(catalogue: PricingCatalogue, id: string): ModelPricing {
395
654
  const model = catalogue.byId.get(id);
@@ -422,7 +681,7 @@ export function cheapestOfTierIn(
422
681
  const candidates = catalogue.models.filter(
423
682
  (m) =>
424
683
  m.tier === tier &&
425
- m.recommendable !== false &&
684
+ isOffered(m) &&
426
685
  (provider === undefined || m.provider === provider),
427
686
  );
428
687
  const first = candidates[0];
@@ -453,46 +712,172 @@ export function listModels(): ModelPricing[] {
453
712
  * possible answers; `on` still decides which answer applies on every call,
454
713
  * so behaviour is unchanged to the millisecond.
455
714
  */
715
+ interface Rates {
716
+ inputPerMTok: number;
717
+ outputPerMTok: number;
718
+ promoApplied: boolean;
719
+ tier: { applied: string | null; decided: boolean; because: string } | null;
720
+ }
721
+
456
722
  const pricingMemo = new WeakMap<
457
723
  ModelPricing,
458
- {
459
- untilMs: number | null;
460
- promo: { inputPerMTok: number; outputPerMTok: number; promoApplied: boolean } | null;
461
- base: { inputPerMTok: number; outputPerMTok: number; promoApplied: boolean };
462
- }
724
+ { untilMs: number | null; promo: Rates | null; base: Rates }
463
725
  >();
464
726
 
727
+ /** The two unconditional answers for one model, parsed once. */
728
+ function cache(model: ModelPricing): {
729
+ untilMs: number | null;
730
+ promo: Rates | null;
731
+ base: Rates;
732
+ } {
733
+ const entry = {
734
+ untilMs: model.promo ? new Date(`${model.promo.until}T23:59:59.999Z`).getTime() : null,
735
+ promo: model.promo
736
+ ? Object.freeze({
737
+ inputPerMTok: model.promo.inputPerMTok,
738
+ outputPerMTok: model.promo.outputPerMTok,
739
+ promoApplied: true,
740
+ tier: null,
741
+ })
742
+ : null,
743
+ base: Object.freeze({
744
+ inputPerMTok: model.inputPerMTok,
745
+ outputPerMTok: model.outputPerMTok,
746
+ promoApplied: false,
747
+ tier: null,
748
+ }),
749
+ };
750
+ pricingMemo.set(model, entry);
751
+ return entry;
752
+ }
753
+
754
+ /**
755
+ * Whether one condition holds, for a call at `on` of `inputTokens` tokens.
756
+ *
757
+ * Returns `null` rather than `false` when the condition cannot be decided,
758
+ * because "this does not apply" and "nobody said" lead to different reports and
759
+ * collapsing them is the oldest mistake in this repository.
760
+ */
761
+ function holds(
762
+ when: TierCondition,
763
+ on: Date,
764
+ inputTokens: number | undefined,
765
+ ): boolean | null {
766
+ if (when.kind === 'utc-hours') {
767
+ const day = on.getUTCDay();
768
+ if (when.weekdaysOnly && (day === 0 || day === 6)) return false;
769
+ return when.hours.includes(on.getUTCHours());
770
+ }
771
+ if (inputTokens === undefined) return null;
772
+ return inputTokens > when.tokens;
773
+ }
774
+
775
+ /**
776
+ * What one call is billed at, and what decided it.
777
+ *
778
+ * `tier` is the field this grew for, and it carries three states rather than
779
+ * two. A model with no tiers gets `null`. A model whose condition could be
780
+ * evaluated gets the tier that applied, or the base rate with `applied: null`
781
+ * and `decided: true`. A model whose condition could **not** be evaluated —
782
+ * a size tier priced without a token count — gets `decided: false`, the
783
+ * **dearer** of the candidate rates, and `because` naming what would settle it.
784
+ *
785
+ * The dearer one is not a shrug. A cost figure that is a ceiling can prove
786
+ * "under budget" and can never surprise a bill; a floor chosen for looking
787
+ * better is the flattering direction this product spends its time refusing.
788
+ * The reader still has to be told, which is what `decided: false` is for, and
789
+ * `pricing.test.js` fails a tiered model whose report drops it.
790
+ */
465
791
  export function effectivePricing(
466
792
  model: ModelPricing,
467
793
  on: Date = new Date(),
468
- ): { inputPerMTok: number; outputPerMTok: number; promoApplied: boolean } {
469
- let memo = pricingMemo.get(model);
470
- if (memo === undefined) {
471
- memo = {
472
- untilMs: model.promo ? new Date(`${model.promo.until}T23:59:59.999Z`).getTime() : null,
473
- promo: model.promo
794
+ context: { inputTokens?: number } = {},
795
+ ): Rates {
796
+ const memo = pricingMemo.get(model) ?? cache(model);
797
+
798
+ /**
799
+ * Tiers first, and a promotion never runs on top of one.
800
+ *
801
+ * No provider here does both, and combining them would be this repository
802
+ * inventing a price out of two real ones — the composed figure the first rule
803
+ * in the doctrine is about. A model that grew both would fail the guard in
804
+ * `pricing.test.js` rather than quietly getting a multiplied rate.
805
+ */
806
+ if (model.tiers !== undefined && model.tiers.length > 0) {
807
+ const undecided: PricingTier[] = [];
808
+ for (const tier of model.tiers) {
809
+ const verdict = holds(tier.when, on, context.inputTokens);
810
+ if (verdict === true) {
811
+ return {
812
+ inputPerMTok: tier.inputPerMTok,
813
+ outputPerMTok: tier.outputPerMTok,
814
+ promoApplied: false,
815
+ tier: { applied: tier.id, decided: true, because: describe(tier.when) },
816
+ };
817
+ }
818
+ if (verdict === null) undecided.push(tier);
819
+ }
820
+
821
+ if (undecided.length === 0) {
822
+ return {
823
+ ...memo.base,
824
+ tier: { applied: null, decided: true, because: 'no conditional rate applies' },
825
+ };
826
+ }
827
+
828
+ /* The dearest candidate, so the figure is a ceiling rather than a wish. */
829
+ const dearest = undecided.reduce((worst, tier) =>
830
+ tier.inputPerMTok > worst.inputPerMTok ? tier : worst,
831
+ );
832
+ const ceiling =
833
+ dearest.inputPerMTok > model.inputPerMTok
474
834
  ? {
475
- inputPerMTok: model.promo.inputPerMTok,
476
- outputPerMTok: model.promo.outputPerMTok,
477
- promoApplied: true,
835
+ inputPerMTok: dearest.inputPerMTok,
836
+ outputPerMTok: dearest.outputPerMTok,
837
+ applied: dearest.id,
478
838
  }
479
- : null,
480
- base: Object.freeze({
481
- inputPerMTok: model.inputPerMTok,
482
- outputPerMTok: model.outputPerMTok,
483
- promoApplied: false,
484
- }),
839
+ : {
840
+ inputPerMTok: model.inputPerMTok,
841
+ outputPerMTok: model.outputPerMTok,
842
+ applied: null,
843
+ };
844
+ return {
845
+ inputPerMTok: ceiling.inputPerMTok,
846
+ outputPerMTok: ceiling.outputPerMTok,
847
+ promoApplied: false,
848
+ tier: {
849
+ applied: ceiling.applied,
850
+ decided: false,
851
+ because: `${describe(dearest.when)}, and the token count was not given`,
852
+ },
485
853
  };
486
- if (memo.promo) Object.freeze(memo.promo);
487
- pricingMemo.set(model, memo);
488
854
  }
855
+
489
856
  if (memo.untilMs !== null && on.getTime() <= memo.untilMs) return memo.promo!;
490
857
  return memo.base;
491
858
  }
492
859
 
860
+ /** One condition as a sentence a reader can check against a provider's page. */
861
+ function describe(when: TierCondition): string {
862
+ if (when.kind === 'input-tokens-above') {
863
+ return `prompts over ${when.tokens.toLocaleString('en-US')} input tokens`;
864
+ }
865
+ const windows = [];
866
+ let start = null;
867
+ const sorted = [...when.hours].sort((a, b) => a - b);
868
+ for (let i = 0; i < sorted.length; i += 1) {
869
+ if (start === null) start = sorted[i]!;
870
+ if (sorted[i]! + 1 !== sorted[i + 1]) {
871
+ windows.push(`${String(start).padStart(2, '0')}:00-${String(sorted[i]! + 1).padStart(2, '0')}:00`);
872
+ start = null;
873
+ }
874
+ }
875
+ return `${windows.join(' and ')} UTC${when.weekdaysOnly ? ', Monday to Friday' : ''}`;
876
+ }
877
+
493
878
  /** Cheapest model of each capability tier, for recommendations. */
494
879
  export function cheapestOfTier(tier: ModelPricing['tier']): ModelPricing {
495
- const candidates = MODELS.filter((m) => m.tier === tier && m.recommendable !== false);
880
+ const candidates = MODELS.filter((m) => m.tier === tier && isOffered(m));
496
881
  const first = candidates[0];
497
882
  if (!first) throw new Error(`No models for tier "${tier}"`);
498
883
  return candidates.reduce((best, m) => (m.inputPerMTok < best.inputPerMTok ? m : best), first);
@@ -514,6 +899,21 @@ export function cheapestOfTier(tier: ModelPricing['tier']): ModelPricing {
514
899
  * a date — an overlay supplies this string, and a wrong one should read as
515
900
  * unknown rather than as a confident number computed from `NaN`.
516
901
  */
902
+ /**
903
+ * The age at which this product stops treating its own price table as current.
904
+ *
905
+ * Three surfaces warn a reader that the figures above may be off — `profile`,
906
+ * the MCP report, the browser's bill — and each of them had typed `45` into
907
+ * its own comparison, with four locale sentences stating it again in prose.
908
+ * Seven copies of one claim, and nothing that would notice when they
909
+ * disagreed; the sentence a reader sees names the number, so a drift between
910
+ * them would have been a surface telling the reader one threshold and applying
911
+ * another.
912
+ *
913
+ * The value is what those surfaces already published, not a new judgement.
914
+ */
915
+ export const STALE_PRICING_DAYS = 45;
916
+
517
917
  export function reviewAgeDays(lastReviewed: string, now: Date): number | null {
518
918
  if (!/^\d{4}-\d{2}-\d{2}$/.test(lastReviewed)) return null;
519
919
  const then = Date.parse(`${lastReviewed}T00:00:00Z`);