@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/suggest.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { getMessages } from './i18n/index.js';
2
+ import type { Locale } from './i18n/types.js';
3
+ import { segment } from './segment.js';
4
+ import { estimateTokens } from './tokenizer.js';
5
+ import type { LlmProvider, TokenCounter } from './types.js';
6
+
7
+ /**
8
+ * Rewrites the rules cannot do, proposed one phrase at a time.
9
+ *
10
+ * The existing LLM pass (`refineWithLlm`) hands the model the whole prompt and
11
+ * takes the whole answer back, which makes it all-or-nothing: when the result
12
+ * fails a safety check the author gets *nothing*, and when it passes they get a
13
+ * wholesale rewrite they have to read end to end to trust. Both halves of that
14
+ * are worse than they need to be.
15
+ *
16
+ * This asks a different question. "Which exact phrases in this prompt say
17
+ * something in more words than they need to?" — and the answer is a list of
18
+ * `before → after` pairs, each one small enough to judge on sight:
19
+ *
20
+ * You should always make sure to → Always
21
+ * It is important to note that → (removed)
22
+ *
23
+ * Nothing is applied unless the caller asks. Eight surviving suggestions out of
24
+ * ten is a useful result; a wholesale rewrite that fails one check is not.
25
+ *
26
+ * ## What makes a suggestion survive
27
+ *
28
+ * The model is a source of proposals, not of truth, so every one is checked
29
+ * against the prompt before it is shown:
30
+ *
31
+ * 1. **`before` must appear in the prompt, byte for byte.** A model that
32
+ * paraphrases what it is quoting has invented a suggestion about text that
33
+ * does not exist, and applying it would do nothing or, worse, match
34
+ * something else.
35
+ * 2. **It must not touch protected content.** Code, URLs, placeholders and XML
36
+ * tags are copied verbatim by every other part of this project, and a
37
+ * suggestion that edits one is refused rather than negotiated.
38
+ * 3. **`after` must not introduce protected content.** A replacement that adds
39
+ * a `{{placeholder}}` or a URL is proposing new semantics, not shorter
40
+ * phrasing.
41
+ * 4. **It must actually save tokens.** A rephrasing that costs the same is a
42
+ * change of style, and this tool is not a style guide.
43
+ * 5. **Overlapping suggestions are dropped, later ones first.** Applying two
44
+ * edits that share characters produces text neither of them described.
45
+ */
46
+
47
+ export interface RewriteSuggestion {
48
+ /** The exact text in the prompt, as it appears there. */
49
+ before: string;
50
+ /** What to put in its place. Empty means "delete this". */
51
+ after: string;
52
+ /** Character offsets in the prompt, one per surviving occurrence. */
53
+ offsets: number[];
54
+ /** Tokens saved if every occurrence is applied. */
55
+ tokensSaved: number;
56
+ }
57
+
58
+ export interface SuggestResult {
59
+ suggestions: RewriteSuggestion[];
60
+ /** Suggestions the model returned that did not survive, with the reason. */
61
+ rejected: Array<{ before: string; after: string; reason: RejectedReason }>;
62
+ provider: string;
63
+ model: string;
64
+ }
65
+
66
+ export type RejectedReason =
67
+ /** `before` is not in the prompt. The model paraphrased what it quoted. */
68
+ | 'not-found'
69
+ /** It would edit a code block, URL, placeholder or tag. */
70
+ | 'touches-protected'
71
+ /** `after` introduces protected content that was not there. */
72
+ | 'introduces-protected'
73
+ /** No shorter than what it replaces. */
74
+ | 'no-saving'
75
+ /** It shares characters with a suggestion already accepted. */
76
+ | 'overlaps';
77
+
78
+ export const SUGGEST_SYSTEM_PROMPT = `You find phrases in a prompt that say something in more words than they need.
79
+
80
+ Return ONLY a JSON array. Each element is {"before": "...", "after": "..."}.
81
+
82
+ Rules:
83
+ - "before" MUST be copied character for character from the prompt. Do not paraphrase it, do not fix its punctuation, do not change its capitalisation. If you cannot copy it exactly, leave it out.
84
+ - "after" says the same thing in fewer words. Use "" to delete the phrase entirely.
85
+ - Preserve meaning exactly. Never change what the prompt asks for, its constraints, its output format or its success criteria.
86
+ - Never touch code, URLs, template placeholders ({{x}}, \${x}, {x}) or XML/HTML tags — do not include them in "before" at all.
87
+ - Keep the original language of the prompt.
88
+ - Prefer a few high-value rewrites to many trivial ones. Return [] if there is nothing worth changing.
89
+
90
+ No explanation, no code fences, no commentary. The array alone.`;
91
+
92
+ export interface SuggestOptions {
93
+ tokenCounter?: TokenCounter;
94
+ locale?: Locale;
95
+ /** Cap on how many survive, highest saving first. Defaults to 20. */
96
+ max?: number;
97
+ }
98
+
99
+ /** Strips a code fence if the model wrapped its JSON despite being told not to. */
100
+ function unwrap(text: string): string {
101
+ const trimmed = text.trim();
102
+ const fenced = /^(?:```|~~~)[a-zA-Z]*\n([\s\S]*?)\n?(?:```|~~~)$/.exec(trimmed);
103
+ return (fenced?.[1] ?? trimmed).trim();
104
+ }
105
+
106
+ /**
107
+ * Character ranges that must not be edited.
108
+ *
109
+ * `segment` returns the pieces in order without offsets, so they are
110
+ * accumulated here rather than recomputed per suggestion.
111
+ */
112
+ function protectedRanges(prompt: string): Array<[number, number]> {
113
+ const ranges: Array<[number, number]> = [];
114
+ let at = 0;
115
+ for (const piece of segment(prompt)) {
116
+ if (piece.kind === 'protected') ranges.push([at, at + piece.text.length]);
117
+ at += piece.text.length;
118
+ }
119
+ return ranges;
120
+ }
121
+
122
+ const overlaps = (a: [number, number], b: [number, number]): boolean =>
123
+ a[0] < b[1] && b[0] < a[1];
124
+
125
+ /** Every offset at which `needle` occurs in `haystack`. */
126
+ function occurrences(haystack: string, needle: string): number[] {
127
+ if (needle === '') return [];
128
+ const found: number[] = [];
129
+ let at = haystack.indexOf(needle);
130
+ while (at !== -1) {
131
+ found.push(at);
132
+ at = haystack.indexOf(needle, at + needle.length);
133
+ }
134
+ return found;
135
+ }
136
+
137
+ /**
138
+ * Asks the provider for rewrites and returns the ones that survive checking.
139
+ *
140
+ * A provider that returns something unparseable yields an empty result rather
141
+ * than throwing: the deterministic rules have already run, and a malformed
142
+ * answer from an optional pass should cost the caller nothing.
143
+ */
144
+ export async function suggestRewrites(
145
+ prompt: string,
146
+ provider: LlmProvider,
147
+ options: SuggestOptions = {},
148
+ ): Promise<SuggestResult> {
149
+ const count = options.tokenCounter ?? estimateTokens;
150
+ const max = options.max ?? 20;
151
+
152
+ const raw = await provider.complete({ system: SUGGEST_SYSTEM_PROMPT, user: prompt });
153
+
154
+ let proposed: Array<{ before?: unknown; after?: unknown }>;
155
+ try {
156
+ const parsed: unknown = JSON.parse(unwrap(raw));
157
+ proposed = Array.isArray(parsed) ? parsed : [];
158
+ } catch {
159
+ return { suggestions: [], rejected: [], provider: provider.name, model: provider.model };
160
+ }
161
+
162
+ const guarded = protectedRanges(prompt);
163
+ const suggestions: RewriteSuggestion[] = [];
164
+ const rejected: SuggestResult['rejected'] = [];
165
+ const taken: Array<[number, number]> = [];
166
+
167
+ for (const item of proposed) {
168
+ if (typeof item?.before !== 'string' || typeof item?.after !== 'string') continue;
169
+ const before = item.before;
170
+ const after = item.after;
171
+ if (before.trim() === '') continue;
172
+
173
+ const reject = (reason: RejectedReason) => rejected.push({ before, after, reason });
174
+
175
+ const at = occurrences(prompt, before);
176
+ if (at.length === 0) {
177
+ reject('not-found');
178
+ continue;
179
+ }
180
+
181
+ // Occurrences that sit clear of protected content. A phrase that appears
182
+ // three times, once inside a code block, is still worth rewriting in the
183
+ // other two — refusing the whole suggestion would be the easier answer and
184
+ // the wrong one.
185
+ const usable = at.filter(
186
+ (start) => !guarded.some((range) => overlaps([start, start + before.length], range)),
187
+ );
188
+ if (usable.length === 0) {
189
+ reject('touches-protected');
190
+ continue;
191
+ }
192
+
193
+ // The replacement must not bring protected content in with it.
194
+ if (after !== '' && segment(after).some((piece) => piece.kind === 'protected')) {
195
+ reject('introduces-protected');
196
+ continue;
197
+ }
198
+
199
+ const saved = (count(before) - count(after)) * usable.length;
200
+ if (saved <= 0) {
201
+ reject('no-saving');
202
+ continue;
203
+ }
204
+
205
+ const free = usable.filter(
206
+ (start) => !taken.some((range) => overlaps([start, start + before.length], range)),
207
+ );
208
+ if (free.length === 0) {
209
+ reject('overlaps');
210
+ continue;
211
+ }
212
+
213
+ for (const start of free) taken.push([start, start + before.length]);
214
+ suggestions.push({
215
+ before,
216
+ after,
217
+ offsets: free,
218
+ tokensSaved: (count(before) - count(after)) * free.length,
219
+ });
220
+ }
221
+
222
+ suggestions.sort((a, b) => b.tokensSaved - a.tokensSaved);
223
+ return {
224
+ suggestions: suggestions.slice(0, max),
225
+ rejected,
226
+ provider: provider.name,
227
+ model: provider.model,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Applies suggestions to the prompt.
233
+ *
234
+ * Right to left, so an earlier edit cannot move the offsets of a later one —
235
+ * the bug that makes every naive implementation of this corrupt long prompts,
236
+ * and one that shows up only when two suggestions are far enough apart that a
237
+ * short test never notices.
238
+ */
239
+ export function applyRewrites(prompt: string, suggestions: readonly RewriteSuggestion[]): string {
240
+ const edits = suggestions
241
+ .flatMap((s) => s.offsets.map((start) => ({ start, end: start + s.before.length, after: s.after })))
242
+ .sort((a, b) => b.start - a.start);
243
+
244
+ let text = prompt;
245
+ for (const edit of edits) {
246
+ text = text.slice(0, edit.start) + edit.after + text.slice(edit.end);
247
+ }
248
+ return text;
249
+ }
250
+
251
+ /** A reason code turned into a sentence, for a report. */
252
+ export function rejectionText(reason: RejectedReason, locale: Locale): string {
253
+ return getMessages(locale).suggest[reason]();
254
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Dependency-free token estimator.
3
+ *
4
+ * This is NOT a real tokenizer: it is a heuristic calibrated per character
5
+ * class. It is built to keep the typical error on ordinary text
6
+ * (English/Spanish, markdown, code) inside ±15%, which is plenty for comparing
7
+ * two versions of the same prompt — but do NOT bill anyone from it.
8
+ *
9
+ * **That band is a design target that has not been measured.** It is printed on
10
+ * every report and every dollar figure descends from it, and until
11
+ * `scripts/measure-token-band.mjs` has been run against the official counting
12
+ * endpoint, nothing in this repository establishes that it holds. It is stated
13
+ * as one number for all text, which is a further assumption: the branches below
14
+ * treat CJK, digits and punctuation quite differently from words, and there is
15
+ * no reason those should land on the same accuracy.
16
+ *
17
+ * `test/token-band.test.js` asserts the band per text type as soon as the ground
18
+ * truth exists. Until then it says so rather than passing quietly.
19
+ *
20
+ * For exact numbers use `countTokensAnthropic` (the official token-counting
21
+ * endpoint, which is free) or pass your own `TokenCounter`.
22
+ */
23
+
24
+ import { SAFE_FETCH_INIT, checkedEndpoint } from './net.js';
25
+
26
+ const CJK = /[぀-ヿ㐀-䶿一-鿿가-힯]/;
27
+ const LETTER = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
28
+ const DIGIT = /[0-9]/;
29
+
30
+ /** Effective word length: non-ASCII characters split into more tokens. */
31
+ function effectiveLength(word: string): number {
32
+ let len = 0;
33
+ for (const ch of word) len += ch.charCodeAt(0) > 127 ? 2 : 1;
34
+ return len;
35
+ }
36
+
37
+ /**
38
+ * Estimates how many tokens `text` occupies.
39
+ *
40
+ * Rules per character class:
41
+ * - words: ~4 effective characters per token (minimum 1)
42
+ * - numbers: ~3 digits per token
43
+ * - punctuation: ~2 marks per token
44
+ * - newlines: ~1 token per 2 consecutive newlines
45
+ * - CJK: 1 token per character
46
+ * - emoji and symbols outside the BMP: 2 tokens
47
+ * - spaces: 0 (absorbed into the following token)
48
+ */
49
+ export function estimateTokens(text: string): number {
50
+ if (!text) return 0;
51
+
52
+ let total = 0;
53
+ let i = 0;
54
+ const chars = Array.from(text);
55
+
56
+ while (i < chars.length) {
57
+ const ch = chars[i]!;
58
+
59
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
60
+ i++;
61
+ continue;
62
+ }
63
+
64
+ if (ch === '\n') {
65
+ let n = 0;
66
+ while (i < chars.length && chars[i] === '\n') {
67
+ n++;
68
+ i++;
69
+ }
70
+ total += Math.ceil(n / 2);
71
+ continue;
72
+ }
73
+
74
+ if (CJK.test(ch)) {
75
+ let n = 0;
76
+ while (i < chars.length && CJK.test(chars[i]!)) {
77
+ n++;
78
+ i++;
79
+ }
80
+ total += n;
81
+ continue;
82
+ }
83
+
84
+ if (LETTER.test(ch)) {
85
+ let word = '';
86
+ while (i < chars.length && (LETTER.test(chars[i]!) || DIGIT.test(chars[i]!))) {
87
+ word += chars[i]!;
88
+ i++;
89
+ }
90
+ total += Math.max(1, Math.ceil(effectiveLength(word) / 4));
91
+ continue;
92
+ }
93
+
94
+ if (DIGIT.test(ch)) {
95
+ let n = 0;
96
+ while (i < chars.length && DIGIT.test(chars[i]!)) {
97
+ n++;
98
+ i++;
99
+ }
100
+ total += Math.ceil(n / 3);
101
+ continue;
102
+ }
103
+
104
+ // Outside the BMP (emoji, unusual symbols): usually 2+ tokens.
105
+ if (ch.codePointAt(0)! > 0xffff) {
106
+ total += 2;
107
+ i++;
108
+ continue;
109
+ }
110
+
111
+ // ASCII punctuation and symbols.
112
+ let n = 0;
113
+ while (
114
+ i < chars.length &&
115
+ !LETTER.test(chars[i]!) &&
116
+ !DIGIT.test(chars[i]!) &&
117
+ !CJK.test(chars[i]!) &&
118
+ chars[i] !== ' ' &&
119
+ chars[i] !== '\n' &&
120
+ chars[i] !== '\t' &&
121
+ chars[i] !== '\r' &&
122
+ chars[i]!.codePointAt(0)! <= 0xffff
123
+ ) {
124
+ n++;
125
+ i++;
126
+ }
127
+ if (n === 0) {
128
+ // Unclassified character: count it as 1 and advance so the loop cannot stall.
129
+ total += 1;
130
+ i++;
131
+ } else {
132
+ total += Math.ceil(n / 2);
133
+ }
134
+ }
135
+
136
+ return total;
137
+ }
138
+
139
+ /** Asynchronous token counter, for remote sources. */
140
+ export type AsyncTokenCounter = (text: string) => Promise<number>;
141
+
142
+ export interface AnthropicCounterOptions {
143
+ apiKey: string;
144
+ /** Model to count against. Token counts are model-specific. */
145
+ model?: string;
146
+ baseUrl?: string;
147
+ fetchImpl?: typeof fetch;
148
+ /** See `OpenAiCompatibleOptions.allowInsecure`: only when you chose the URL. */
149
+ allowInsecure?: boolean;
150
+ }
151
+
152
+ /**
153
+ * Exact counter using the official `/v1/messages/count_tokens` endpoint.
154
+ * The endpoint does not bill tokens, so you can use it freely to measure.
155
+ *
156
+ * The third door, and the one nobody had looked at: this takes a `baseUrl` and
157
+ * sends an `x-api-key` to it. Both providers were hardened at the boundary and
158
+ * this was left with no check at all, because it is called a counter rather
159
+ * than a provider. It goes through the same gate now.
160
+ */
161
+ export function countTokensAnthropic(options: AnthropicCounterOptions): AsyncTokenCounter {
162
+ const {
163
+ apiKey,
164
+ model = 'claude-opus-5',
165
+ baseUrl = 'https://api.anthropic.com',
166
+ fetchImpl = fetch,
167
+ allowInsecure = false,
168
+ } = options;
169
+
170
+ const endpoint = checkedEndpoint(baseUrl, { allowInsecure, name: 'anthropic-count-tokens' });
171
+
172
+ return async (text: string): Promise<number> => {
173
+ if (!text.trim()) return 0;
174
+ const res = await fetchImpl(`${endpoint}/v1/messages/count_tokens`, {
175
+ ...SAFE_FETCH_INIT,
176
+ method: 'POST',
177
+ headers: {
178
+ 'content-type': 'application/json',
179
+ 'x-api-key': apiKey,
180
+ 'anthropic-version': '2023-06-01',
181
+ },
182
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: text }] }),
183
+ });
184
+ if (!res.ok) {
185
+ throw new Error(`count_tokens failed (${res.status}): ${await res.text()}`);
186
+ }
187
+ const data = (await res.json()) as { input_tokens: number };
188
+ return data.input_tokens;
189
+ };
190
+ }