@trazum/core 1.8.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 (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/advisories.d.ts +23 -0
  4. package/dist/advisories.d.ts.map +1 -0
  5. package/dist/advisories.js +376 -0
  6. package/dist/advisories.js.map +1 -0
  7. package/dist/aws-sigv4.d.ts +88 -0
  8. package/dist/aws-sigv4.d.ts.map +1 -0
  9. package/dist/aws-sigv4.js +117 -0
  10. package/dist/aws-sigv4.js.map +1 -0
  11. package/dist/baseline.d.ts +171 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +273 -0
  14. package/dist/baseline.js.map +1 -0
  15. package/dist/cache.d.ts +26 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +28 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/changes.d.ts +29 -0
  20. package/dist/changes.d.ts.map +1 -0
  21. package/dist/changes.js +142 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/compare.d.ts +65 -0
  24. package/dist/compare.d.ts.map +1 -0
  25. package/dist/compare.js +58 -0
  26. package/dist/compare.js.map +1 -0
  27. package/dist/config-schema.d.ts +118 -0
  28. package/dist/config-schema.d.ts.map +1 -0
  29. package/dist/config-schema.js +315 -0
  30. package/dist/config-schema.js.map +1 -0
  31. package/dist/config.d.ts +47 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +112 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/detect.d.ts +70 -0
  36. package/dist/detect.d.ts.map +1 -0
  37. package/dist/detect.js +228 -0
  38. package/dist/detect.js.map +1 -0
  39. package/dist/evaluate.d.ts +98 -0
  40. package/dist/evaluate.d.ts.map +1 -0
  41. package/dist/evaluate.js +110 -0
  42. package/dist/evaluate.js.map +1 -0
  43. package/dist/extract.d.ts +81 -0
  44. package/dist/extract.d.ts.map +1 -0
  45. package/dist/extract.js +280 -0
  46. package/dist/extract.js.map +1 -0
  47. package/dist/gcp-auth.d.ts +58 -0
  48. package/dist/gcp-auth.d.ts.map +1 -0
  49. package/dist/gcp-auth.js +113 -0
  50. package/dist/gcp-auth.js.map +1 -0
  51. package/dist/glob.d.ts +49 -0
  52. package/dist/glob.d.ts.map +1 -0
  53. package/dist/glob.js +154 -0
  54. package/dist/glob.js.map +1 -0
  55. package/dist/host.d.ts +30 -0
  56. package/dist/host.d.ts.map +1 -0
  57. package/dist/host.js +69 -0
  58. package/dist/host.js.map +1 -0
  59. package/dist/i18n/en.d.ts +4 -0
  60. package/dist/i18n/en.d.ts.map +1 -0
  61. package/dist/i18n/en.js +168 -0
  62. package/dist/i18n/en.js.map +1 -0
  63. package/dist/i18n/es.d.ts +4 -0
  64. package/dist/i18n/es.d.ts.map +1 -0
  65. package/dist/i18n/es.js +168 -0
  66. package/dist/i18n/es.js.map +1 -0
  67. package/dist/i18n/index.d.ts +36 -0
  68. package/dist/i18n/index.d.ts.map +1 -0
  69. package/dist/i18n/index.js +50 -0
  70. package/dist/i18n/index.js.map +1 -0
  71. package/dist/i18n/types.d.ts +180 -0
  72. package/dist/i18n/types.d.ts.map +1 -0
  73. package/dist/i18n/types.js +11 -0
  74. package/dist/i18n/types.js.map +1 -0
  75. package/dist/index.d.ts +66 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/llm.d.ts +226 -0
  80. package/dist/llm.d.ts.map +1 -0
  81. package/dist/llm.js +485 -0
  82. package/dist/llm.js.map +1 -0
  83. package/dist/nearest.d.ts +20 -0
  84. package/dist/nearest.d.ts.map +1 -0
  85. package/dist/nearest.js +54 -0
  86. package/dist/nearest.js.map +1 -0
  87. package/dist/net.d.ts +90 -0
  88. package/dist/net.d.ts.map +1 -0
  89. package/dist/net.js +203 -0
  90. package/dist/net.js.map +1 -0
  91. package/dist/node.d.ts +32 -0
  92. package/dist/node.d.ts.map +1 -0
  93. package/dist/node.js +33 -0
  94. package/dist/node.js.map +1 -0
  95. package/dist/openrouter.d.ts +25 -0
  96. package/dist/openrouter.d.ts.map +1 -0
  97. package/dist/openrouter.js +72 -0
  98. package/dist/openrouter.js.map +1 -0
  99. package/dist/optimize.d.ts +38 -0
  100. package/dist/optimize.d.ts.map +1 -0
  101. package/dist/optimize.js +183 -0
  102. package/dist/optimize.js.map +1 -0
  103. package/dist/otlp.d.ts +91 -0
  104. package/dist/otlp.d.ts.map +1 -0
  105. package/dist/otlp.js +102 -0
  106. package/dist/otlp.js.map +1 -0
  107. package/dist/phrases.d.ts +169 -0
  108. package/dist/phrases.d.ts.map +1 -0
  109. package/dist/phrases.js +939 -0
  110. package/dist/phrases.js.map +1 -0
  111. package/dist/pricing-overlay.d.ts +55 -0
  112. package/dist/pricing-overlay.d.ts.map +1 -0
  113. package/dist/pricing-overlay.js +241 -0
  114. package/dist/pricing-overlay.js.map +1 -0
  115. package/dist/pricing.d.ts +115 -0
  116. package/dist/pricing.d.ts.map +1 -0
  117. package/dist/pricing.js +400 -0
  118. package/dist/pricing.js.map +1 -0
  119. package/dist/profile.d.ts +71 -0
  120. package/dist/profile.d.ts.map +1 -0
  121. package/dist/profile.js +55 -0
  122. package/dist/profile.js.map +1 -0
  123. package/dist/promptfoo.d.ts +58 -0
  124. package/dist/promptfoo.d.ts.map +1 -0
  125. package/dist/promptfoo.js +149 -0
  126. package/dist/promptfoo.js.map +1 -0
  127. package/dist/prune.d.ts +91 -0
  128. package/dist/prune.d.ts.map +1 -0
  129. package/dist/prune.js +110 -0
  130. package/dist/prune.js.map +1 -0
  131. package/dist/reorder.d.ts +82 -0
  132. package/dist/reorder.d.ts.map +1 -0
  133. package/dist/reorder.js +215 -0
  134. package/dist/reorder.js.map +1 -0
  135. package/dist/review.d.ts +54 -0
  136. package/dist/review.d.ts.map +1 -0
  137. package/dist/review.js +131 -0
  138. package/dist/review.js.map +1 -0
  139. package/dist/rules.d.ts +5 -0
  140. package/dist/rules.d.ts.map +1 -0
  141. package/dist/rules.js +279 -0
  142. package/dist/rules.js.map +1 -0
  143. package/dist/savings.d.ts +36 -0
  144. package/dist/savings.d.ts.map +1 -0
  145. package/dist/savings.js +83 -0
  146. package/dist/savings.js.map +1 -0
  147. package/dist/segment.d.ts +8 -0
  148. package/dist/segment.d.ts.map +1 -0
  149. package/dist/segment.js +74 -0
  150. package/dist/segment.js.map +1 -0
  151. package/dist/shared-prefix.d.ts +63 -0
  152. package/dist/shared-prefix.d.ts.map +1 -0
  153. package/dist/shared-prefix.js +151 -0
  154. package/dist/shared-prefix.js.map +1 -0
  155. package/dist/similarity.d.ts +13 -0
  156. package/dist/similarity.d.ts.map +1 -0
  157. package/dist/similarity.js +30 -0
  158. package/dist/similarity.js.map +1 -0
  159. package/dist/structure.d.ts +144 -0
  160. package/dist/structure.d.ts.map +1 -0
  161. package/dist/structure.js +455 -0
  162. package/dist/structure.js.map +1 -0
  163. package/dist/suggest.d.ts +100 -0
  164. package/dist/suggest.d.ts.map +1 -0
  165. package/dist/suggest.js +151 -0
  166. package/dist/suggest.js.map +1 -0
  167. package/dist/tokenizer.d.ts +57 -0
  168. package/dist/tokenizer.d.ts.map +1 -0
  169. package/dist/tokenizer.js +157 -0
  170. package/dist/tokenizer.js.map +1 -0
  171. package/dist/types.d.ts +296 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/walk.d.ts +40 -0
  176. package/dist/walk.d.ts.map +1 -0
  177. package/dist/walk.js +95 -0
  178. package/dist/walk.js.map +1 -0
  179. package/package.json +56 -0
  180. package/src/advisories.ts +431 -0
  181. package/src/aws-sigv4.ts +174 -0
  182. package/src/baseline.ts +390 -0
  183. package/src/cache.ts +54 -0
  184. package/src/changes.ts +158 -0
  185. package/src/compare.ts +131 -0
  186. package/src/config-schema.ts +451 -0
  187. package/src/config.ts +161 -0
  188. package/src/detect.ts +312 -0
  189. package/src/evaluate.ts +188 -0
  190. package/src/extract.ts +336 -0
  191. package/src/gcp-auth.ts +166 -0
  192. package/src/glob.ts +160 -0
  193. package/src/host.ts +90 -0
  194. package/src/i18n/en.ts +236 -0
  195. package/src/i18n/es.ts +236 -0
  196. package/src/i18n/index.ts +68 -0
  197. package/src/i18n/types.ts +230 -0
  198. package/src/index.ts +228 -0
  199. package/src/llm.ts +708 -0
  200. package/src/nearest.ts +61 -0
  201. package/src/net.ts +233 -0
  202. package/src/node.ts +63 -0
  203. package/src/openrouter.ts +125 -0
  204. package/src/optimize.ts +228 -0
  205. package/src/otlp.ts +179 -0
  206. package/src/phrases.ts +1047 -0
  207. package/src/pricing-overlay.ts +319 -0
  208. package/src/pricing.ts +468 -0
  209. package/src/profile.ts +124 -0
  210. package/src/promptfoo.ts +213 -0
  211. package/src/prune.ts +211 -0
  212. package/src/reorder.ts +307 -0
  213. package/src/review.ts +180 -0
  214. package/src/rules.ts +324 -0
  215. package/src/savings.ts +121 -0
  216. package/src/segment.ts +106 -0
  217. package/src/shared-prefix.ts +198 -0
  218. package/src/similarity.ts +28 -0
  219. package/src/structure.ts +652 -0
  220. package/src/suggest.ts +254 -0
  221. package/src/tokenizer.ts +190 -0
  222. package/src/types.ts +323 -0
  223. package/src/walk.ts +117 -0
package/src/pricing.ts ADDED
@@ -0,0 +1,468 @@
1
+ import type { ModelPricing } from './types.js';
2
+
3
+ /**
4
+ * Model and pricing catalogue (USD per million tokens).
5
+ *
6
+ * Source: official Claude API documentation. Prices change: check
7
+ * `PRICING_LAST_REVIEWED` and update this file before making budget decisions.
8
+ * Amazon Bedrock and Vertex AI pricing is set by each partner and is NOT the
9
+ * pricing in this table.
10
+ */
11
+ export const PRICING_LAST_REVIEWED = '2026-06-24';
12
+
13
+ /** Cost multipliers relative to the input price. */
14
+ /**
15
+ * The defaults, which are Anthropic's numbers.
16
+ *
17
+ * Still exported and still correct for every Anthropic model, but no longer the
18
+ * whole story: a model can override any of these through `multipliers`, and
19
+ * anything computing a cost should go through `multipliersFor` rather than
20
+ * reading these directly. A cache read is ~10% of input on Anthropic and ~50%
21
+ * on OpenAI, and using one number for both invents a saving.
22
+ */
23
+ export const COST_MULTIPLIERS = {
24
+ /** Cache write with a 5-minute TTL. */
25
+ cacheWrite5m: 1.25,
26
+ /** Cache write with a 1-hour TTL. */
27
+ cacheWrite1h: 2.0,
28
+ /** Cache read: ~10% of the input price. */
29
+ cacheRead: 0.1,
30
+ /** Batch API: 50% discount on input and output. */
31
+ batch: 0.5,
32
+ } as const;
33
+
34
+ /**
35
+ * The multipliers that apply to one model, defaults filled in.
36
+ *
37
+ * `batch` stays `null` when the provider has no batch API, which is different
38
+ * from "unspecified": the first should stop the advisory firing, the second
39
+ * should fall back to the default.
40
+ */
41
+ export function multipliersFor(model: ModelPricing): {
42
+ cacheWrite5m: number;
43
+ cacheWrite1h: number;
44
+ cacheRead: number;
45
+ batch: number | null;
46
+ } {
47
+ const m = model.multipliers ?? {};
48
+ return {
49
+ cacheWrite5m: m.cacheWrite5m ?? COST_MULTIPLIERS.cacheWrite5m,
50
+ cacheWrite1h: m.cacheWrite1h ?? COST_MULTIPLIERS.cacheWrite1h,
51
+ cacheRead: m.cacheRead ?? COST_MULTIPLIERS.cacheRead,
52
+ batch: m.batch === undefined ? COST_MULTIPLIERS.batch : m.batch,
53
+ };
54
+ }
55
+
56
+ export const MODELS: ModelPricing[] = [
57
+ {
58
+ id: 'claude-fable-5',
59
+ provider: 'anthropic',
60
+ displayName: 'Claude Fable 5',
61
+ inputPerMTok: 10,
62
+ outputPerMTok: 50,
63
+ contextWindow: 1_000_000,
64
+ cacheMinTokens: 512,
65
+ capability: 'frontier',
66
+ tier: 'frontier',
67
+ notes: 'Highest capability. Requires 30-day data retention.',
68
+ },
69
+ {
70
+ id: 'claude-mythos-5',
71
+ provider: 'anthropic',
72
+ displayName: 'Claude Mythos 5',
73
+ recommendable: false,
74
+ inputPerMTok: 10,
75
+ outputPerMTok: 50,
76
+ contextWindow: 1_000_000,
77
+ cacheMinTokens: 512,
78
+ capability: 'frontier',
79
+ tier: 'frontier',
80
+ notes: 'Available only through Project Glasswing.',
81
+ },
82
+ {
83
+ id: 'claude-opus-5',
84
+ provider: 'anthropic',
85
+ displayName: 'Claude Opus 5',
86
+ inputPerMTok: 5,
87
+ outputPerMTok: 25,
88
+ contextWindow: 1_000_000,
89
+ cacheMinTokens: 512,
90
+ capability: 'large',
91
+ tier: 'opus',
92
+ notes: '512-token cache minimum: caches prompts that would miss on Opus 4.6.',
93
+ },
94
+ {
95
+ id: 'claude-opus-4-8',
96
+ provider: 'anthropic',
97
+ displayName: 'Claude Opus 4.8',
98
+ inputPerMTok: 5,
99
+ outputPerMTok: 25,
100
+ contextWindow: 1_000_000,
101
+ cacheMinTokens: 1024,
102
+ capability: 'large',
103
+ tier: 'opus',
104
+ },
105
+ {
106
+ id: 'claude-opus-4-7',
107
+ provider: 'anthropic',
108
+ displayName: 'Claude Opus 4.7',
109
+ inputPerMTok: 5,
110
+ outputPerMTok: 25,
111
+ contextWindow: 1_000_000,
112
+ cacheMinTokens: 2048,
113
+ capability: 'large',
114
+ tier: 'opus',
115
+ },
116
+ {
117
+ id: 'claude-opus-4-6',
118
+ provider: 'anthropic',
119
+ displayName: 'Claude Opus 4.6',
120
+ inputPerMTok: 5,
121
+ outputPerMTok: 25,
122
+ contextWindow: 1_000_000,
123
+ cacheMinTokens: 4096,
124
+ capability: 'large',
125
+ tier: 'opus',
126
+ },
127
+ {
128
+ id: 'claude-sonnet-5',
129
+ provider: 'anthropic',
130
+ displayName: 'Claude Sonnet 5',
131
+ inputPerMTok: 3,
132
+ outputPerMTok: 15,
133
+ contextWindow: 1_000_000,
134
+ cacheMinTokens: 1024,
135
+ capability: 'mid',
136
+ tier: 'sonnet',
137
+ promo: { inputPerMTok: 2, outputPerMTok: 10, until: '2026-08-31' },
138
+ notes: 'Introductory pricing of 2/10 until 2026-08-31; 3/15 afterwards.',
139
+ },
140
+ {
141
+ id: 'claude-sonnet-4-6',
142
+ provider: 'anthropic',
143
+ displayName: 'Claude Sonnet 4.6',
144
+ inputPerMTok: 3,
145
+ outputPerMTok: 15,
146
+ contextWindow: 1_000_000,
147
+ cacheMinTokens: 1024,
148
+ capability: 'mid',
149
+ tier: 'sonnet',
150
+ },
151
+ {
152
+ id: 'claude-haiku-4-5',
153
+ provider: 'anthropic',
154
+ displayName: 'Claude Haiku 4.5',
155
+ inputPerMTok: 1,
156
+ outputPerMTok: 5,
157
+ contextWindow: 200_000,
158
+ cacheMinTokens: 4096,
159
+ capability: 'small',
160
+ tier: 'haiku',
161
+ notes: '200K context window and a 64K output cap, smaller than the rest.',
162
+ },
163
+
164
+ // ------------------------------------------------------------------------
165
+ // OpenAI
166
+ //
167
+ // Caching is AUTOMATIC above 1,024 tokens — there is no cache_control to set,
168
+ // and a cached read costs about half the input price rather than a tenth. The
169
+ // reordering advice is identical (a prefix is a prefix), the marker advice is
170
+ // not, which is why `caching` and `multipliers` exist at all.
171
+ // ------------------------------------------------------------------------
172
+ {
173
+ id: 'gpt-5',
174
+ provider: 'openai',
175
+ displayName: 'GPT-5',
176
+ inputPerMTok: 1.25,
177
+ outputPerMTok: 10,
178
+ contextWindow: 400_000,
179
+ cacheMinTokens: 1024,
180
+ caching: 'automatic',
181
+ multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
182
+ capability: 'frontier',
183
+ tier: 'frontier',
184
+ notes: 'Caching is automatic above 1,024 tokens; there is no cache_control to set.',
185
+ },
186
+ {
187
+ id: 'gpt-5-mini',
188
+ provider: 'openai',
189
+ displayName: 'GPT-5 mini',
190
+ inputPerMTok: 0.25,
191
+ outputPerMTok: 2,
192
+ contextWindow: 400_000,
193
+ cacheMinTokens: 1024,
194
+ caching: 'automatic',
195
+ multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
196
+ capability: 'mid',
197
+ tier: 'sonnet',
198
+ },
199
+ {
200
+ id: 'gpt-5-nano',
201
+ provider: 'openai',
202
+ displayName: 'GPT-5 nano',
203
+ inputPerMTok: 0.05,
204
+ outputPerMTok: 0.4,
205
+ contextWindow: 400_000,
206
+ cacheMinTokens: 1024,
207
+ caching: 'automatic',
208
+ multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
209
+ capability: 'small',
210
+ tier: 'haiku',
211
+ },
212
+
213
+ // ------------------------------------------------------------------------
214
+ // Google
215
+ // ------------------------------------------------------------------------
216
+ {
217
+ id: 'gemini-2.5-pro',
218
+ provider: 'google',
219
+ displayName: 'Gemini 2.5 Pro',
220
+ inputPerMTok: 1.25,
221
+ outputPerMTok: 10,
222
+ contextWindow: 1_048_576,
223
+ cacheMinTokens: 2048,
224
+ caching: 'explicit',
225
+ multipliers: { cacheRead: 0.25, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
226
+ capability: 'large',
227
+ tier: 'opus',
228
+ notes: 'Context caching is billed for storage per hour as well as per read.',
229
+ },
230
+ {
231
+ id: 'gemini-2.5-flash',
232
+ provider: 'google',
233
+ displayName: 'Gemini 2.5 Flash',
234
+ inputPerMTok: 0.3,
235
+ outputPerMTok: 2.5,
236
+ contextWindow: 1_048_576,
237
+ cacheMinTokens: 1024,
238
+ caching: 'explicit',
239
+ multipliers: { cacheRead: 0.25, cacheWrite5m: 1, cacheWrite1h: 1, batch: 0.5 },
240
+ capability: 'mid',
241
+ tier: 'sonnet',
242
+ },
243
+
244
+ // ------------------------------------------------------------------------
245
+ // Moonshot
246
+ // ------------------------------------------------------------------------
247
+ {
248
+ id: 'kimi-k2',
249
+ provider: 'moonshot',
250
+ displayName: 'Kimi K2',
251
+ inputPerMTok: 0.6,
252
+ outputPerMTok: 2.5,
253
+ contextWindow: 256_000,
254
+ cacheMinTokens: 1024,
255
+ caching: 'automatic',
256
+ multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: null },
257
+ capability: 'mid',
258
+ tier: 'sonnet',
259
+ notes: 'No batch API: the batch advisory stays quiet rather than offering a discount you cannot buy.',
260
+ },
261
+
262
+ // ------------------------------------------------------------------------
263
+ // DeepSeek
264
+ // ------------------------------------------------------------------------
265
+ {
266
+ id: 'deepseek-v3',
267
+ provider: 'deepseek',
268
+ displayName: 'DeepSeek V3',
269
+ inputPerMTok: 0.27,
270
+ outputPerMTok: 1.1,
271
+ contextWindow: 128_000,
272
+ cacheMinTokens: 1024,
273
+ caching: 'automatic',
274
+ multipliers: { cacheRead: 0.1, cacheWrite5m: 1, cacheWrite1h: 1, batch: null },
275
+ capability: 'mid',
276
+ tier: 'sonnet',
277
+ },
278
+
279
+ // ------------------------------------------------------------------------
280
+ // xAI
281
+ // ------------------------------------------------------------------------
282
+ {
283
+ id: 'grok-4',
284
+ provider: 'xai',
285
+ displayName: 'Grok 4',
286
+ inputPerMTok: 3,
287
+ outputPerMTok: 15,
288
+ contextWindow: 256_000,
289
+ cacheMinTokens: 1024,
290
+ caching: 'automatic',
291
+ multipliers: { cacheRead: 0.25, cacheWrite5m: 1, cacheWrite1h: 1, batch: null },
292
+ capability: 'large',
293
+ tier: 'opus',
294
+ },
295
+
296
+ // ------------------------------------------------------------------------
297
+ // Mistral
298
+ // ------------------------------------------------------------------------
299
+ {
300
+ id: 'mistral-large-2',
301
+ provider: 'mistral',
302
+ displayName: 'Mistral Large 2',
303
+ inputPerMTok: 2,
304
+ outputPerMTok: 6,
305
+ contextWindow: 128_000,
306
+ cacheMinTokens: 0,
307
+ caching: 'none',
308
+ multipliers: { batch: 0.5 },
309
+ capability: 'large',
310
+ tier: 'opus',
311
+ notes: 'No prompt caching: reordering still helps readability but saves nothing here.',
312
+ },
313
+ ];
314
+
315
+ export const DEFAULT_MODEL = 'claude-opus-5';
316
+
317
+ /**
318
+ * A set of prices to work from.
319
+ *
320
+ * Prices change on someone else's schedule, and until 1.0 correcting one meant
321
+ * upgrading the library — which is backwards: a stale price is a wrong number in
322
+ * a budget decision, and nobody should have to take a dependency bump to fix it.
323
+ *
324
+ * So the catalogue is a **value**, not module state. The bundled one below is the
325
+ * default, and `applyPricingOverlay` returns a *new* catalogue with local
326
+ * corrections layered on. Nothing mutates: a caller who overlays prices does not
327
+ * change what any other caller sees, and two catalogues can exist in one process
328
+ * — which is what makes it testable and what stops one consumer's local prices
329
+ * leaking into another's report.
330
+ */
331
+ export interface PricingCatalogue {
332
+ models: ModelPricing[];
333
+ byId: Map<string, ModelPricing>;
334
+ /** The date the prices in this catalogue were last checked. */
335
+ lastReviewed: string;
336
+ /** Ids whose bundled prices an overlay replaced. Empty for the bundled set. */
337
+ overriddenModels: string[];
338
+ /** Ids an overlay introduced that the bundled catalogue does not have. */
339
+ addedModels: string[];
340
+ }
341
+
342
+ function makeCatalogue(
343
+ models: ModelPricing[],
344
+ lastReviewed: string,
345
+ overriddenModels: string[] = [],
346
+ addedModels: string[] = [],
347
+ ): PricingCatalogue {
348
+ return {
349
+ models,
350
+ byId: new Map(models.map((m) => [m.id, m])),
351
+ lastReviewed,
352
+ overriddenModels,
353
+ addedModels,
354
+ };
355
+ }
356
+
357
+ /** The prices compiled into this release. */
358
+ export const BUNDLED_CATALOGUE: PricingCatalogue = makeCatalogue(MODELS, PRICING_LAST_REVIEWED);
359
+
360
+ /** Looks a model up in a specific catalogue. */
361
+ export function modelFrom(catalogue: PricingCatalogue, id: string): ModelPricing {
362
+ const model = catalogue.byId.get(id);
363
+ if (!model) {
364
+ throw new Error(
365
+ `Unknown model: "${id}". Available: ${catalogue.models.map((m) => m.id).join(', ')}`,
366
+ );
367
+ }
368
+ return model;
369
+ }
370
+
371
+ /**
372
+ * Cheapest model of a tier within a catalogue.
373
+ *
374
+ * Compared on the **effective** price, so a model in a promotional window is
375
+ * ranked at what it actually costs today rather than at its list price.
376
+ */
377
+ export function cheapestOfTierIn(
378
+ catalogue: PricingCatalogue,
379
+ tier: ModelPricing['tier'],
380
+ on: Date = new Date(),
381
+ /**
382
+ * Restrict to one provider. Omit to search the whole catalogue, which is what
383
+ * this did before other providers existed — kept as the default so the
384
+ * signature stays additive, though the advisory always passes one: switching
385
+ * vendor is a migration, not a cheaper model.
386
+ */
387
+ provider?: string,
388
+ ): ModelPricing {
389
+ const candidates = catalogue.models.filter(
390
+ (m) =>
391
+ m.tier === tier &&
392
+ m.recommendable !== false &&
393
+ (provider === undefined || m.provider === provider),
394
+ );
395
+ const first = candidates[0];
396
+ if (!first) throw new Error(`No models for tier "${tier}"`);
397
+ return candidates.reduce(
398
+ (best, m) =>
399
+ effectivePricing(m, on).inputPerMTok < effectivePricing(best, on).inputPerMTok ? m : best,
400
+ first,
401
+ );
402
+ }
403
+
404
+ export function getModel(id: string): ModelPricing {
405
+ return modelFrom(BUNDLED_CATALOGUE, id);
406
+ }
407
+
408
+ export function listModels(): ModelPricing[] {
409
+ return [...MODELS];
410
+ }
411
+
412
+ /** Effective price on a given date, applying any live promotion. */
413
+ export function effectivePricing(
414
+ model: ModelPricing,
415
+ on: Date = new Date(),
416
+ ): { inputPerMTok: number; outputPerMTok: number; promoApplied: boolean } {
417
+ if (model.promo) {
418
+ const until = new Date(`${model.promo.until}T23:59:59.999Z`);
419
+ if (on.getTime() <= until.getTime()) {
420
+ return {
421
+ inputPerMTok: model.promo.inputPerMTok,
422
+ outputPerMTok: model.promo.outputPerMTok,
423
+ promoApplied: true,
424
+ };
425
+ }
426
+ }
427
+ return {
428
+ inputPerMTok: model.inputPerMTok,
429
+ outputPerMTok: model.outputPerMTok,
430
+ promoApplied: false,
431
+ };
432
+ }
433
+
434
+ /** Cheapest model of each capability tier, for recommendations. */
435
+ export function cheapestOfTier(tier: ModelPricing['tier']): ModelPricing {
436
+ const candidates = MODELS.filter((m) => m.tier === tier && m.recommendable !== false);
437
+ const first = candidates[0];
438
+ if (!first) throw new Error(`No models for tier "${tier}"`);
439
+ return candidates.reduce((best, m) => (m.inputPerMTok < best.inputPerMTok ? m : best), first);
440
+ }
441
+
442
+ /**
443
+ * Whole days between a `YYYY-MM-DD` review date and now, or `null`.
444
+ *
445
+ * Every dollar figure Trazum prints descends from a price list, and the list
446
+ * carries the date it was checked. Printing only that date makes the reader do
447
+ * arithmetic against today to learn the one thing they wanted to know — whether
448
+ * to trust it — and a reader who is not already suspicious will not bother.
449
+ *
450
+ * `now` is a parameter for the reason `computeSavings` takes a `Date`: a function
451
+ * that reads the clock can only be asserted for shape.
452
+ *
453
+ * Compared at UTC midnight on both sides, so the answer does not change by one
454
+ * depending on what time of day the command runs. `null` for anything that is not
455
+ * a date — an overlay supplies this string, and a wrong one should read as
456
+ * unknown rather than as a confident number computed from `NaN`.
457
+ */
458
+ export function reviewAgeDays(lastReviewed: string, now: Date): number | null {
459
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(lastReviewed)) return null;
460
+ const then = Date.parse(`${lastReviewed}T00:00:00Z`);
461
+ if (Number.isNaN(then)) return null;
462
+
463
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
464
+ const days = Math.round((today - then) / 86_400_000);
465
+ // A future date is a typo or a clock problem, not an age. Reported as unknown
466
+ // rather than as a negative number of days, which reads like a bug either way.
467
+ return days < 0 ? null : days;
468
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { segment } from './segment.js';
2
+ import { findExamples, findRestatedFormat } from './structure.js';
3
+ import { estimateTokens } from './tokenizer.js';
4
+ import type { TokenCounter } from './types.js';
5
+
6
+ /**
7
+ * What a prompt is made of, so a team can decide which one to fix first.
8
+ *
9
+ * ## Why this is not a score
10
+ *
11
+ * The obvious shape for "which prompts should we optimise?" is a number out of
12
+ * a hundred, and it is the wrong shape. `Complexity: 74` cannot be argued with,
13
+ * cannot be reproduced by hand, and does not tell anybody what to do on Monday.
14
+ * Worse, the weights that turn four measurements into one number are invented —
15
+ * and once invented they get tuned until the ranking looks right, which is
16
+ * fitting the metric to the answer.
17
+ *
18
+ * So this returns **the measurements**, each with a definition you can check
19
+ * against the prompt in front of you, and ranks on the one quantity that is not
20
+ * a matter of opinion: **what optimising it would actually save.** That figure
21
+ * comes from running the deterministic rules, not from a formula.
22
+ *
23
+ * The structural facts are the *explanation* for a prompt's position in that
24
+ * ranking, not a substitute for it. "1,204 tokens across 8 sentences" says why
25
+ * a prompt is worth looking at; "$310 a month recoverable" says whether it is
26
+ * worth looking at before the other thirty-nine.
27
+ *
28
+ * ## What each measurement means, exactly
29
+ *
30
+ * - **sentences** — spans ending in `.`, `!`, `?`, `。`, `!`, `?`, or a line
31
+ * break where the line ends without punctuation (a bullet is a sentence).
32
+ * Protected content is excluded, so a code block is not forty sentences.
33
+ * - **tokensPerSentence** — the verbosity signal, and the only ratio here. It
34
+ * is length-independent: a padded 300-token prompt and a padded 3,000-token
35
+ * one look the same, which is the point. Reported with units, never as an
36
+ * index.
37
+ * - **examples / exampleTokens** — from `findExamples`, the same detector the
38
+ * advisories use. Few-shot examples are usually the largest single block in
39
+ * an expensive prompt and the easiest to trim by one.
40
+ * - **formatTokens** — a restated output format (a JSON schema written out
41
+ * twice, or written out at all when the API takes a schema parameter).
42
+ * - **protectedTokens** — code, URLs, placeholders and tags. Counted separately
43
+ * because **no amount of optimising will touch them**, and a prompt that is
44
+ * 80% code has far less headroom than its size suggests. Leaving this out is
45
+ * how a ranking sends somebody to spend an afternoon on a file that cannot
46
+ * move.
47
+ */
48
+
49
+ export interface PromptProfile {
50
+ tokens: number;
51
+ /** Tokens that cannot be touched: code, URLs, placeholders, tags. */
52
+ protectedTokens: number;
53
+ sentences: number;
54
+ /** `tokens / sentences`, rounded to one decimal. Zero when there are none. */
55
+ tokensPerSentence: number;
56
+ examples: number;
57
+ exampleTokens: number;
58
+ /** Tokens in a restated output format, or 0. */
59
+ formatTokens: number;
60
+ }
61
+
62
+ const SENTENCE_END = /[.!?。!?]+/;
63
+
64
+ /**
65
+ * Sentences in the mutable part of the prompt.
66
+ *
67
+ * Deliberately simple and stated rather than clever. An abbreviation splits a
68
+ * sentence in two here, and that is accepted: the number is used as a
69
+ * denominator for a verbosity ratio, where a few percent of noise changes
70
+ * nothing, and the alternative is a sentence tokeniser this project would then
71
+ * have to defend.
72
+ */
73
+ export function countSentences(prompt: string): number {
74
+ let total = 0;
75
+
76
+ for (const piece of segment(prompt)) {
77
+ if (piece.kind === 'protected') continue;
78
+
79
+ for (const line of piece.text.split('\n')) {
80
+ const trimmed = line.trim();
81
+ if (trimmed === '') continue;
82
+
83
+ const parts = trimmed
84
+ .split(SENTENCE_END)
85
+ .map((part) => part.trim())
86
+ .filter((part) => part !== '');
87
+
88
+ // A line with no terminal punctuation is still one thing being said — a
89
+ // bullet, a heading, a rule in a list. Counting it as zero would make
90
+ // every bulleted prompt look infinitely verbose.
91
+ total += Math.max(1, parts.length);
92
+ }
93
+ }
94
+
95
+ return total;
96
+ }
97
+
98
+ export interface ProfileOptions {
99
+ count?: TokenCounter;
100
+ }
101
+
102
+ /** Measures a prompt. Deterministic, offline, and free. */
103
+ export function profilePrompt(prompt: string, options: ProfileOptions = {}): PromptProfile {
104
+ const count = options.count ?? estimateTokens;
105
+
106
+ const tokens = count(prompt);
107
+ const protectedTokens = segment(prompt)
108
+ .filter((piece) => piece.kind === 'protected')
109
+ .reduce((sum, piece) => sum + count(piece.text), 0);
110
+
111
+ const sentences = countSentences(prompt);
112
+ const examples = findExamples(prompt, count);
113
+ const format = findRestatedFormat(prompt, count);
114
+
115
+ return {
116
+ tokens,
117
+ protectedTokens,
118
+ sentences,
119
+ tokensPerSentence: sentences === 0 ? 0 : Math.round((tokens / sentences) * 10) / 10,
120
+ examples: examples.length,
121
+ exampleTokens: examples.reduce((sum, block) => sum + block.tokens, 0),
122
+ formatTokens: format?.restatedTokens ?? 0,
123
+ };
124
+ }