@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/rules.ts ADDED
@@ -0,0 +1,324 @@
1
+ import {
2
+ EMPHASIS_PREFIXES,
3
+ FILLER,
4
+ HEDGES,
5
+ INTENSIFIERS,
6
+ POLITENESS,
7
+ SELF_CHECK,
8
+ SHOUTED_WORDS,
9
+ VERBOSE_PHRASES,
10
+ } from './phrases.js';
11
+ import { jaccard, normalizeForCompare } from './similarity.js';
12
+ import { EXAMPLE_FIELD_LINE } from './structure.js';
13
+ import type { RuleId } from './i18n/types.js';
14
+ import type { Rule } from './types.js';
15
+
16
+ /** Escapes a literal so it can go inside a regular expression. */
17
+ function escapeRe(literal: string): string {
18
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ }
20
+
21
+ /**
22
+ * Builds a regex with accent-aware word boundaries.
23
+ * JavaScript's `\b` only knows ASCII, so "de" would match inside "ánde".
24
+ * Lookarounds over \p{L} avoid that.
25
+ */
26
+ function phraseRegex(phrase: string, flags = 'giu'): RegExp {
27
+ const body = escapeRe(phrase).replace(/\s+/g, '\\s+');
28
+ return new RegExp(`(?<![\\p{L}\\p{N}_])${body}(?![\\p{L}\\p{N}_])`, flags);
29
+ }
30
+
31
+ /** Cleans up what deleting a phrase leaves behind: spaces and orphan punctuation. */
32
+ function tidyAfterRemoval(text: string): string {
33
+ return (
34
+ text
35
+ // Repeated spaces inside a line (leading indentation untouched).
36
+ .replace(/([^\n\S])[^\S\n]+/g, '$1')
37
+ // Comma or semicolon left with a space in front of it.
38
+ .replace(/[^\S\n]+([,;:.!?])/g, '$1')
39
+ // Commas duplicated by the removal.
40
+ .replace(/,(\s*,)+/g, ',')
41
+ // Punctuation left at the start of a line.
42
+ .replace(/^[^\S\n]*[,;:]\s*/gm, '')
43
+ // Spanish opening exclamation/question mark left with no content.
44
+ .replace(/[¡¿]+(?=[^\S\n]*(?:[!?.,;:]|$))/gm, '')
45
+ // Duplicated sentence-final punctuation when two fragments join ("answer.!").
46
+ .replace(/([.!?])[^\S\n]*[.!?]+/g, '$1')
47
+ // A line left holding nothing but punctuation.
48
+ .replace(/^[^\S\n]*[.,;:!?¡¿]+[^\S\n]*$/gm, '')
49
+ // A single leading space is removal residue, not indentation.
50
+ .replace(/^[^\S\n](?=\S)/gm, '')
51
+ // Trailing spaces.
52
+ .replace(/[^\S\n]+$/gm, '')
53
+ // Restore the capital when the removal left a sentence starting lowercase.
54
+ // Sentence starts: beginning of the text, after strong punctuation, and
55
+ // the start of a new paragraph.
56
+ .replace(
57
+ /(^|[.!?]\s+|\n\n[^\S\n]*)(\p{Ll})/gu,
58
+ (_m, pre: string, ch: string) => pre + ch.toUpperCase(),
59
+ )
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Deletion regex: on top of the phrase, it swallows the commas that delimited
65
+ * it. Without this, removing an aside such as "if you don't mind," leaves
66
+ * "and, classify it".
67
+ */
68
+ function dropRegex(phrase: string): RegExp {
69
+ const body = escapeRe(phrase).replace(/\s+/g, '\\s+');
70
+ // The boundary check goes AFTER the optional comma: in front of it, the "y"
71
+ // of "y, si no te importa," would fail the match and the comma would stay.
72
+ return new RegExp(
73
+ `(?:,[^\\S\\n]*)?(?<![\\p{L}\\p{N}_])${body}[^\\S\\n]*,?(?![\\p{L}\\p{N}_])`,
74
+ 'giu',
75
+ );
76
+ }
77
+
78
+ /** A rule that deletes a set of phrases. */
79
+ function dropRule(id: RuleId, level: Rule['level'], phrases: readonly string[]): Rule {
80
+ // Longest first, so "thank you very much" wins over "thanks".
81
+ const sorted = [...phrases].sort((a, b) => b.length - a.length);
82
+ const regexes = sorted.map((p) => dropRegex(p));
83
+
84
+ return {
85
+ id,
86
+ level,
87
+ apply(text) {
88
+ let hits = 0;
89
+ let out = text;
90
+ for (const re of regexes) {
91
+ out = out.replace(re, () => {
92
+ hits++;
93
+ return '';
94
+ });
95
+ }
96
+ return { text: hits > 0 ? tidyAfterRemoval(out) : out, hits };
97
+ },
98
+ };
99
+ }
100
+
101
+ /** Keeps the original leading capital when substituting. */
102
+ function matchCase(original: string, replacement: string): string {
103
+ if (!replacement) return replacement;
104
+ const firstChar = original[0];
105
+ if (firstChar && firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase()) {
106
+ return replacement[0]!.toUpperCase() + replacement.slice(1);
107
+ }
108
+ return replacement;
109
+ }
110
+
111
+ const whitespaceRule: Rule = {
112
+ id: 'whitespace',
113
+ level: 'safe',
114
+ apply(text) {
115
+ let hits = 0;
116
+ let out = text;
117
+
118
+ // The lookbehind is load-bearing, not decoration. Without it the engine
119
+ // restarts this match at every position inside a whitespace run, and when
120
+ // the run does not end the line it fails from each one — quadratic, and
121
+ // 17 seconds on a 100 KB line of spaces that the HTTP API happily accepts.
122
+ // Anchoring to the start of a run means each run is tried exactly once.
123
+ out = out.replace(/(?<![^\S\n])[^\S\n]+$/gm, () => {
124
+ hits++;
125
+ return '';
126
+ });
127
+ // Collapse repeated spaces only after the first non-space character.
128
+ out = out.replace(/(\S)[^\S\n]{2,}/g, (_m, ch: string) => {
129
+ hits++;
130
+ return `${ch} `;
131
+ });
132
+ out = out.replace(/\n{3,}/g, () => {
133
+ hits++;
134
+ return '\n\n';
135
+ });
136
+ out = out.replace(/\t/g, () => {
137
+ hits++;
138
+ return ' ';
139
+ });
140
+
141
+ return { text: out, hits };
142
+ },
143
+ };
144
+
145
+ const decorationRule: Rule = {
146
+ id: 'decoration',
147
+ level: 'safe',
148
+ apply(text) {
149
+ let hits = 0;
150
+ let out = text;
151
+
152
+ // A whole line of 8 or more identical decorative characters.
153
+ out = out.replace(/^[^\S\n]*([=\-_*~#])\1{7,}[^\S\n]*$/gm, () => {
154
+ hits++;
155
+ return '';
156
+ });
157
+ // Repeated exclamation or question marks, opening ones included.
158
+ out = out.replace(/([!?¡¿])\1{1,}/g, (_m, ch: string) => {
159
+ hits++;
160
+ return ch;
161
+ });
162
+ // Clean up the blank lines the deletion just left behind.
163
+ if (hits > 0) out = out.replace(/\n{3,}/g, '\n\n');
164
+
165
+ return { text: out, hits };
166
+ },
167
+ };
168
+
169
+ const verbosePhrasesRule: Rule = {
170
+ id: 'verbose-phrases',
171
+ level: 'safe',
172
+ apply(text) {
173
+ let hits = 0;
174
+ let out = text;
175
+ // Longest first so the most specific substitution wins.
176
+ const entries = [...VERBOSE_PHRASES].sort((a, b) => b[0].length - a[0].length);
177
+ for (const [from, to] of entries) {
178
+ const re = phraseRegex(from);
179
+ out = out.replace(re, (match) => {
180
+ hits++;
181
+ return matchCase(match, to);
182
+ });
183
+ }
184
+ return { text: hits > 0 ? tidyAfterRemoval(out) : out, hits };
185
+ },
186
+ };
187
+
188
+ const politenessRule = dropRule('politeness', 'safe', POLITENESS);
189
+ const fillerRule = dropRule('filler', 'safe', FILLER);
190
+ const intensifiersRule = dropRule('intensifiers', 'aggressive', INTENSIFIERS);
191
+ const hedgesRule = dropRule('hedges', 'aggressive', HEDGES);
192
+ const selfCheckRule = dropRule('self-check', 'aggressive', SELF_CHECK);
193
+
194
+ const emphasisRule: Rule = {
195
+ id: 'emphasis',
196
+ level: 'aggressive',
197
+ apply(text) {
198
+ let hits = 0;
199
+ let out = text;
200
+
201
+ for (const prefix of EMPHASIS_PREFIXES) {
202
+ const re = new RegExp(`^([^\\S\\n]*)${escapeRe(prefix)}\\s*:\\s*`, 'gm');
203
+ out = out.replace(re, (_m, indent: string) => {
204
+ hits++;
205
+ return indent;
206
+ });
207
+ }
208
+
209
+ for (const word of SHOUTED_WORDS) {
210
+ const re = phraseRegex(word, 'gu'); // case-sensitive: only the shouted form
211
+ out = out.replace(re, (match) => {
212
+ hits++;
213
+ return match.toLowerCase();
214
+ });
215
+ }
216
+
217
+ // Dropping "IMPORTANT:" leaves the sentence starting lowercase: recapitalise.
218
+ return { text: hits > 0 ? tidyAfterRemoval(out) : out, hits };
219
+ },
220
+ };
221
+
222
+ const duplicateLinesRule: Rule = {
223
+ id: 'duplicate-lines',
224
+ level: 'safe',
225
+ apply(text) {
226
+ const lines = text.split('\n');
227
+ const seen = new Set<string>();
228
+ const kept: string[] = [];
229
+ let hits = 0;
230
+
231
+ for (const line of lines) {
232
+ const normalized = normalizeForCompare(line);
233
+ // A labelled example field is data, not repetition: two examples sharing
234
+ // an output line show that two inputs map to the same answer. Dropping
235
+ // the second leaves that example with no output.
236
+ if (normalized.length >= 25 && !EXAMPLE_FIELD_LINE.test(line)) {
237
+ if (seen.has(normalized)) {
238
+ hits++;
239
+ continue;
240
+ }
241
+ seen.add(normalized);
242
+ }
243
+ kept.push(line);
244
+ }
245
+
246
+ let out = kept.join('\n');
247
+ if (hits > 0) out = out.replace(/\n{3,}/g, '\n\n');
248
+ return { text: out, hits };
249
+ },
250
+ };
251
+
252
+ const duplicateBlocksRule: Rule = {
253
+ id: 'duplicate-blocks',
254
+ level: 'safe',
255
+ apply(text) {
256
+ const blocks = text.split(/\n{2,}/);
257
+ const seen = new Set<string>();
258
+ const kept: string[] = [];
259
+ let hits = 0;
260
+
261
+ for (const block of blocks) {
262
+ const normalized = normalizeForCompare(block);
263
+ if (normalized.length >= 40) {
264
+ if (seen.has(normalized)) {
265
+ hits++;
266
+ continue;
267
+ }
268
+ seen.add(normalized);
269
+ }
270
+ kept.push(block);
271
+ }
272
+
273
+ return { text: kept.join('\n\n'), hits };
274
+ },
275
+ };
276
+
277
+ const nearDuplicateBlocksRule: Rule = {
278
+ id: 'near-duplicate-blocks',
279
+ level: 'aggressive',
280
+ apply(text) {
281
+ const blocks = text.split(/\n{2,}/);
282
+ const keptNormalized: string[] = [];
283
+ const kept: string[] = [];
284
+ let hits = 0;
285
+
286
+ for (const block of blocks) {
287
+ const normalized = normalizeForCompare(block);
288
+ if (normalized.length >= 60) {
289
+ const isDuplicate = keptNormalized.some((prev) => jaccard(prev, normalized) >= 0.92);
290
+ if (isDuplicate) {
291
+ hits++;
292
+ continue;
293
+ }
294
+ keptNormalized.push(normalized);
295
+ }
296
+ kept.push(block);
297
+ }
298
+
299
+ return { text: kept.join('\n\n'), hits };
300
+ },
301
+ };
302
+
303
+ /** Every rule, in the order they must run. */
304
+ export const RULES: readonly Rule[] = [
305
+ // Whole-block deletions first, so the rest works over less text.
306
+ duplicateBlocksRule,
307
+ nearDuplicateBlocksRule,
308
+ duplicateLinesRule,
309
+ // Then the phrase-level rules.
310
+ verbosePhrasesRule,
311
+ politenessRule,
312
+ fillerRule,
313
+ hedgesRule,
314
+ intensifiersRule,
315
+ selfCheckRule,
316
+ emphasisRule,
317
+ // And finally typographic cleanup, which picks up what the others left.
318
+ decorationRule,
319
+ whitespaceRule,
320
+ ];
321
+
322
+ export function getRule(id: string): Rule | undefined {
323
+ return RULES.find((r) => r.id === id);
324
+ }
package/src/savings.ts ADDED
@@ -0,0 +1,121 @@
1
+ import {
2
+ BUNDLED_CATALOGUE,
3
+ COST_MULTIPLIERS,
4
+ effectivePricing,
5
+ modelFrom,
6
+ multipliersFor,
7
+ } from './pricing.js';
8
+ import type { PricingCatalogue } from './pricing.js';
9
+ import type { CostBreakdown, SavingsReport, UsageProfile } from './types.js';
10
+
11
+ /**
12
+ * Cost of a single call.
13
+ *
14
+ * `batchDiscount` defaults to Anthropic's 50% rather than being read from the
15
+ * model, because this function takes prices rather than a model. Callers that
16
+ * have the model — which is all of them inside this package — pass its own,
17
+ * and a provider with no batch API passes 1.
18
+ */
19
+ export function costOfCall(
20
+ inputTokens: number,
21
+ outputTokens: number,
22
+ inputPerMTok: number,
23
+ outputPerMTok: number,
24
+ batch: boolean,
25
+ batchDiscount: number = COST_MULTIPLIERS.batch,
26
+ ): CostBreakdown {
27
+ const discount = batch ? batchDiscount : 1;
28
+ const inputUsd = (inputTokens / 1_000_000) * inputPerMTok * discount;
29
+ const outputUsd = (outputTokens / 1_000_000) * outputPerMTok * discount;
30
+ return { inputUsd, outputUsd, totalUsd: inputUsd + outputUsd };
31
+ }
32
+
33
+ function scale(cost: CostBreakdown, factor: number): CostBreakdown {
34
+ return {
35
+ inputUsd: cost.inputUsd * factor,
36
+ outputUsd: cost.outputUsd * factor,
37
+ totalUsd: cost.totalUsd * factor,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Compares cost before and after optimising.
43
+ *
44
+ * Important: output tokens are held constant. A shorter prompt often produces
45
+ * somewhat shorter answers, but that depends on the task and cannot be
46
+ * promised, so the saving reported here comes exclusively from input tokens.
47
+ */
48
+ export function computeSavings(
49
+ tokensBefore: number,
50
+ tokensAfter: number,
51
+ usage: UsageProfile,
52
+ on: Date = new Date(),
53
+ pricing: PricingCatalogue = BUNDLED_CATALOGUE,
54
+ ): SavingsReport {
55
+ const model = modelFrom(pricing, usage.model);
56
+ const { inputPerMTok, outputPerMTok, promoApplied } = effectivePricing(model, on);
57
+ // A provider with no batch API gets no discount even when the caller ticked
58
+ // the box: `batchEligible` describes the work, not what the provider sells.
59
+ const batchDiscount = multipliersFor(model).batch ?? 1;
60
+
61
+ const before = costOfCall(
62
+ tokensBefore,
63
+ usage.avgOutputTokens,
64
+ inputPerMTok,
65
+ outputPerMTok,
66
+ usage.batchEligible,
67
+ batchDiscount,
68
+ );
69
+ const after = costOfCall(
70
+ tokensAfter,
71
+ usage.avgOutputTokens,
72
+ inputPerMTok,
73
+ outputPerMTok,
74
+ usage.batchEligible,
75
+ batchDiscount,
76
+ );
77
+
78
+ const monthBefore = scale(before, usage.callsPerMonth);
79
+ const monthAfter = scale(after, usage.callsPerMonth);
80
+ const monthlySavingsUsd = monthBefore.totalUsd - monthAfter.totalUsd;
81
+
82
+ return {
83
+ model: model.id,
84
+ modelDisplayName: model.displayName,
85
+ promoApplied,
86
+ perCall: { before, after },
87
+ perMonth: { before: monthBefore, after: monthAfter },
88
+ monthlySavingsUsd,
89
+ monthlySavingsPct:
90
+ monthBefore.totalUsd > 0 ? (monthlySavingsUsd / monthBefore.totalUsd) * 100 : 0,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Formats a USD amount with precision suited to its magnitude.
96
+ * Currency formatting stays in `en-US` on purpose: these are US dollar prices
97
+ * from a US price list, and showing them the same way everywhere avoids
98
+ * confusion when a report is shared across locales.
99
+ */
100
+ export function formatUsd(value: number): string {
101
+ if (value === 0) return '$0';
102
+ const abs = Math.abs(value);
103
+ if (abs < 0.01) return `$${value.toFixed(5)}`;
104
+ if (abs < 1) return `$${value.toFixed(4)}`;
105
+ if (abs < 1000) return `$${value.toFixed(2)}`;
106
+ return `$${value.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
107
+ }
108
+
109
+ /**
110
+ * Formats a USD amount that can legitimately be negative, with an explicit
111
+ * sign.
112
+ *
113
+ * `formatUsd` renders a negative as `$-30.80`, which reads as a typo. In a
114
+ * comparison every cost line can go either way and the sign carries the whole
115
+ * meaning, so it goes in front of the currency where a reader expects it.
116
+ */
117
+ export function formatSignedUsd(value: number): string {
118
+ if (value === 0) return '$0';
119
+ const rendered = formatUsd(Math.abs(value));
120
+ return value > 0 ? `+${rendered}` : `-${rendered}`;
121
+ }
package/src/segment.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { ProtectionKind, Segment } from './types.js';
2
+
3
+ /**
4
+ * Splits the prompt into mutable and protected slices.
5
+ *
6
+ * Protected content is NEVER touched. Compressing a code block, a URL or a
7
+ * template placeholder would break the prompt, and that is exactly the failure
8
+ * that makes a prompt optimiser useless.
9
+ */
10
+
11
+ interface PatternDef {
12
+ kind: ProtectionKind;
13
+ regex: RegExp;
14
+ /**
15
+ * Characters to trim from the end of a match. Needed for URLs: the full stop
16
+ * in "see https://example.com/guide." belongs to the sentence, not the URL,
17
+ * and protecting it leaves duplicated punctuation once the rest is cleaned.
18
+ */
19
+ trimTrailing?: RegExp;
20
+ }
21
+
22
+ // Order matters: the first pattern to match at a position wins.
23
+ const PATTERNS: PatternDef[] = [
24
+ // Fenced code blocks with ``` or ~~~ (unclosed blocks run to the end).
25
+ { kind: 'fenced-code', regex: /(?:```|~~~)[\s\S]*?(?:```|~~~|$)/g },
26
+ // Indented blocks of 4+ spaces are left to the rules: they are ambiguous in markdown.
27
+ { kind: 'url', regex: /\b(?:https?|ftp):\/\/[^\s<>"')\]]+/g, trimTrailing: /[.,;:!?]+$/ },
28
+ // Template placeholders: {{var}}, {var}, ${var}, {% tag %}, <<VAR>>, %(var)s
29
+ {
30
+ kind: 'placeholder',
31
+ regex:
32
+ /\{\{[^{}]*\}\}|\{%[\s\S]*?%\}|\$\{[^{}]*\}|<<[A-Z0-9_]+>>|%\([^()]*\)[sdfr]|\{[A-Za-z_][A-Za-z0-9_.]*\}/g,
33
+ },
34
+ // Inline code spans.
35
+ { kind: 'inline-code', regex: /`[^`\n]+`/g },
36
+ // XML/HTML tags, heavily used to structure prompts.
37
+ { kind: 'xml-tag', regex: /<\/?[A-Za-z][\w:.-]*(?:\s[^<>]*?)?\/?>/g },
38
+ ];
39
+
40
+ interface Match {
41
+ start: number;
42
+ end: number;
43
+ kind: ProtectionKind;
44
+ }
45
+
46
+ /** Splits the text into segments, marking what must not be modified. */
47
+ export function segment(text: string): Segment[] {
48
+ const matches: Match[] = [];
49
+
50
+ for (const { kind, regex, trimTrailing } of PATTERNS) {
51
+ // Copy the regex so lastIndex is not shared between calls.
52
+ const re = new RegExp(regex.source, regex.flags);
53
+ let m: RegExpExecArray | null;
54
+ while ((m = re.exec(text)) !== null) {
55
+ let matched = m[0];
56
+ if (matched.length === 0) {
57
+ re.lastIndex++;
58
+ continue;
59
+ }
60
+ if (trimTrailing) matched = matched.replace(trimTrailing, '');
61
+ if (matched.length === 0) continue;
62
+ matches.push({ start: m.index, end: m.index + matched.length, kind });
63
+ }
64
+ }
65
+
66
+ // Sort by start and, on a tie, longest span first.
67
+ matches.sort((a, b) => a.start - b.start || b.end - a.end);
68
+
69
+ // Drop overlaps, keeping the first match at each position.
70
+ const kept: Match[] = [];
71
+ let cursor = 0;
72
+ for (const match of matches) {
73
+ if (match.start < cursor) continue;
74
+ kept.push(match);
75
+ cursor = match.end;
76
+ }
77
+
78
+ const segments: Segment[] = [];
79
+ let pos = 0;
80
+ for (const match of kept) {
81
+ if (match.start > pos) {
82
+ segments.push({ kind: 'mutable', text: text.slice(pos, match.start) });
83
+ }
84
+ segments.push({
85
+ kind: 'protected',
86
+ protection: match.kind,
87
+ text: text.slice(match.start, match.end),
88
+ });
89
+ pos = match.end;
90
+ }
91
+ if (pos < text.length) {
92
+ segments.push({ kind: 'mutable', text: text.slice(pos) });
93
+ }
94
+
95
+ return segments;
96
+ }
97
+
98
+ /** Joins segments back into a single string. */
99
+ export function join(segments: Segment[]): string {
100
+ return segments.map((s) => s.text).join('');
101
+ }
102
+
103
+ /** Returns only the text of the protected segments, in order. */
104
+ export function protectedTexts(segments: Segment[]): string[] {
105
+ return segments.filter((s) => s.kind === 'protected').map((s) => s.text);
106
+ }