@trazum/core 1.8.0 → 1.10.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.
package/src/advisories.ts CHANGED
@@ -7,7 +7,7 @@ import type { PricingCatalogue } from './pricing.js';
7
7
  import { formatUsd } from './savings.js';
8
8
  import { analyzeExamples, findContradictions, findMovableSchema,
9
9
  findRestatedFormat } from './structure.js';
10
- import { estimateTokens } from './tokenizer.js';
10
+ import { ESTIMATE_ERROR_BAND_PCT, estimateTokens } from './tokenizer.js';
11
11
  import type { Advisory, ModelPricing, TokenCounter, UsageProfile } from './types.js';
12
12
 
13
13
  function countSignals(haystack: string, signals: readonly string[]): number {
@@ -125,7 +125,25 @@ export function buildAdvisories(
125
125
  const monthlyOutputUsd =
126
126
  (usage.avgOutputTokens / 1_000_000) * outputPerMTok * usage.callsPerMonth * batchFactor;
127
127
 
128
- // --- Context window ---
128
+ /**
129
+ * --- Context window ---
130
+ *
131
+ * The third place an estimate was compared against a hard threshold and the
132
+ * answer stated as fact, after `cache-prefix-reorder` and `prompt-caching`. This
133
+ * one has no dollar figure and is the most absolute of the three: **"The call
134
+ * will fail."**
135
+ *
136
+ * With a ±10% band it fails in both directions. An estimated 1,050,000 tokens
137
+ * against a 1,000,000 window can truly be 945,000 — the call succeeds and the
138
+ * reader has been sent to split a prompt that fitted. And an estimated 990,000
139
+ * can truly be 1,089,000, which does not fit, and nothing said anything at all.
140
+ *
141
+ * The silent direction is the worse one. A prompt over the window fails
142
+ * outright rather than degrading, so there is no partial result to notice.
143
+ */
144
+ const estimated = count === estimateTokens;
145
+ const band = ESTIMATE_ERROR_BAND_PCT / 100;
146
+
129
147
  if (tokensAfter > model.contextWindow) {
130
148
  advisories.push({
131
149
  id: 'context-overflow',
@@ -134,6 +152,20 @@ export function buildAdvisories(
134
152
  tokens: tokensAfter,
135
153
  modelName: model.displayName,
136
154
  contextWindow: model.contextWindow,
155
+ // Only an estimate can be uncertain. A caller who counted exactly is told
156
+ // the call fails, because it does.
157
+ uncertain: estimated && tokensAfter * (1 - band) <= model.contextWindow,
158
+ }),
159
+ estimatedMonthlyUsd: null,
160
+ });
161
+ } else if (estimated && tokensAfter * (1 + band) > model.contextWindow) {
162
+ advisories.push({
163
+ id: 'context-near-limit',
164
+ severity: 'warning',
165
+ ...t.advisories.contextNearLimit({
166
+ tokens: tokensAfter,
167
+ modelName: model.displayName,
168
+ contextWindow: model.contextWindow,
137
169
  }),
138
170
  estimatedMonthlyUsd: null,
139
171
  });
@@ -185,6 +217,22 @@ export function buildAdvisories(
185
217
  readPct: Math.round(rates.cacheRead * 100),
186
218
  writePct: Math.round(rates.cacheWrite5m * 100),
187
219
  explicit: (model.caching ?? 'explicit') === 'explicit',
220
+ /**
221
+ * The mirror of `couldReachMinimum` on `below-cache-minimum`, and the
222
+ * asymmetry between them was a real gap: that one hedged an estimate
223
+ * landing just *under* the threshold, while this one promised money on
224
+ * an estimate landing just *over* it. With a ±10% band an estimated
225
+ * 528-token prefix can truly be 475, and then nothing caches at all.
226
+ *
227
+ * The cautionary direction is the one that needed it, because this is
228
+ * the side with a dollar figure attached. Only when the number is an
229
+ * estimate: a caller who supplied their own counter has an
230
+ * authoritative prefix and hedging it would push them toward a check
231
+ * they have already done.
232
+ */
233
+ nearMinimum:
234
+ count === estimateTokens &&
235
+ cache.stablePrefixTokens * (1 - ESTIMATE_ERROR_BAND_PCT / 100) < minTokens,
188
236
  }),
189
237
  estimatedMonthlyUsd: saving,
190
238
  });
@@ -207,17 +255,65 @@ export function buildAdvisories(
207
255
  prefixTokens: cache.stablePrefixTokens,
208
256
  totalTokens: tokensAfter,
209
257
  mentionLowerMinimum: minTokens > 512,
258
+ /**
259
+ * Only when the number is an estimate and near the line.
260
+ *
261
+ * `count` defaults to `estimateTokens`; a caller who supplied their own
262
+ * counter — `--exact-tokens`, or the official endpoint — gets an
263
+ * authoritative number and no hedge, because hedging a measured figure
264
+ * is its own kind of dishonesty.
265
+ */
266
+ couldReachMinimum:
267
+ count === estimateTokens &&
268
+ cache.stablePrefixTokens * (1 + ESTIMATE_ERROR_BAND_PCT / 100) >= minTokens,
210
269
  }),
211
270
  estimatedMonthlyUsd: null,
212
271
  });
213
272
  }
214
273
 
215
- // Stable content placed AFTER the first placeholder: never cached today,
216
- // but moving it in front would make it cacheable.
274
+ /**
275
+ * Stable content placed AFTER the first placeholder: never cached today, and
276
+ * cacheable if it moves in front.
277
+ *
278
+ * **The prefix it would produce has to clear the minimum, and it did not used
279
+ * to be checked.** That was a money figure in the flattering direction, which
280
+ * is the one fault this file exists to avoid. On a 306-token support prompt
281
+ * against Claude Opus 5's 512-token minimum, the best prefix a rearrangement
282
+ * can build is 302 — so nothing caches, and the advisory offered $48.67 a
283
+ * month that cannot be collected.
284
+ *
285
+ * Worse, it said so in the same report as `below-cache-minimum`, which was
286
+ * telling the reader caching would not work here at all. Two advisories
287
+ * contradicting each other, and the one with a dollar sign winning the
288
+ * argument.
289
+ *
290
+ * `reorderForCache` already refused these prompts for exactly this reason, so
291
+ * the tool's advice and its action disagreed: follow the advice, run
292
+ * `--reorder`, and watch nothing happen.
293
+ */
294
+ /**
295
+ * The best prefix any rearrangement could build, compared strictly.
296
+ *
297
+ * **No band hedge here, and that was tried first.** Widening the comparison by
298
+ * ±10% — on the same reasoning that makes `below-cache-minimum` hedge near the
299
+ * line — opened a window between 466 and 512 tokens where this advisory
300
+ * offered a saving and `reorderForCache` refused to perform it. That is the
301
+ * fault being fixed, reintroduced one layer up, and a test caught it.
302
+ *
303
+ * The near-the-line case is already handled and in the right place:
304
+ * `below-cache-minimum` says the estimate is close to the threshold and names
305
+ * `--exact-tokens`. Settle the number and both this advisory and the command
306
+ * work from the same certainty. Two components disagreeing is worse than one
307
+ * of them being briefly quiet.
308
+ */
309
+ const reorderedPrefix = cache.stablePrefixTokens + cache.staticTokensAfter;
310
+ const reachableAfterReorder = reorderedPrefix >= minTokens;
311
+
217
312
  if (
218
313
  cache.firstPlaceholder &&
219
314
  cache.staticTokensAfter >= 200 &&
220
- cache.staticTokensAfter >= tokensAfter * 0.3
315
+ cache.staticTokensAfter >= tokensAfter * 0.3 &&
316
+ reachableAfterReorder
221
317
  ) {
222
318
  const movableShare = tokensAfter > 0 ? cache.staticTokensAfter / tokensAfter : 0;
223
319
  const saving = monthlyInputUsd * movableShare * Math.max(0, 1 - factor);
@@ -228,6 +324,13 @@ export function buildAdvisories(
228
324
  staticTokensAfter: cache.staticTokensAfter,
229
325
  sharePct: Math.round(movableShare * 100),
230
326
  placeholder: cache.firstPlaceholder,
327
+ /**
328
+ * Trazum can do this, and until now it told you to do it by hand.
329
+ * `reorderForCache` moves whole blocks, refuses any block carrying a
330
+ * backward reference, and refuses everything after one — so the command
331
+ * is the safe way to attempt what the prose was describing.
332
+ */
333
+ command: 'trazum optimize <file> --reorder',
231
334
  }),
232
335
  estimatedMonthlyUsd: saving > 0 ? saving : null,
233
336
  });
package/src/i18n/en.ts CHANGED
@@ -91,9 +91,18 @@ export const en: CoreMessages = {
91
91
  },
92
92
 
93
93
  advisories: {
94
- contextOverflow: ({ tokens, modelName, contextWindow }) => ({
95
- title: 'The prompt does not fit in the context window',
96
- detail: `The optimised prompt is ~${n(tokens)} tokens and ${modelName} accepts ${n(contextWindow)}. The call will fail: split the content or move to a model with a larger window.`,
94
+ contextOverflow: ({ tokens, modelName, contextWindow, uncertain }) => ({
95
+ title: uncertain
96
+ ? 'The prompt probably does not fit in the context window'
97
+ : 'The prompt does not fit in the context window',
98
+ detail: uncertain
99
+ ? `The optimised prompt is ~${n(tokens)} tokens against ${modelName}'s ${n(contextWindow)}. That count is an estimate and it is close to the line, so the call will probably fail but might not — settle it with --exact-tokens before rewriting anything. The counting endpoint is free. If it does exceed the window, split the content or move to a model with a larger one.`
100
+ : `The optimised prompt is ~${n(tokens)} tokens and ${modelName} accepts ${n(contextWindow)}. The call will fail: split the content or move to a model with a larger window.`,
101
+ }),
102
+
103
+ contextNearLimit: ({ tokens, modelName, contextWindow }) => ({
104
+ title: 'The prompt may not fit in the context window',
105
+ detail: `The optimised prompt is ~${n(tokens)} tokens against ${modelName}'s ${n(contextWindow)}, which fits — but that count is an estimate and its error range reaches past the window, so the real prompt may not. A call that exceeds the window fails outright rather than degrading, and nothing else here warns about it. Confirm with --exact-tokens; the counting endpoint is free.`,
97
106
  }),
98
107
 
99
108
  promptCaching: ({
@@ -106,6 +115,7 @@ export const en: CoreMessages = {
106
115
  readPct,
107
116
  writePct,
108
117
  explicit,
118
+ nearMinimum,
109
119
  }) => {
110
120
  const scope = placeholder
111
121
  ? `The stable prefix — everything before the first placeholder ${placeholder} — is ~${n(prefixTokens)} of the prompt's ${n(totalTokens)} tokens, and clears ${modelName}'s ${n(minTokens)}-token cacheable minimum.`
@@ -113,9 +123,12 @@ export const en: CoreMessages = {
113
123
  const how = explicit
114
124
  ? 'Put the cache marker at the end of the stable prefix: any byte that changes before the cut invalidates everything after it.'
115
125
  : `${modelName} caches automatically above its minimum, so there is nothing to set — but the same rule applies: any byte that changes before the cut invalidates everything after it.`;
126
+ const hedge = nearMinimum
127
+ ? ` One caveat on the figure: that prefix count is an estimate and it is close to the line, so the real one may be below the ${n(minTokens)}-token minimum — in which case nothing caches and this saving is not there. Settle it with --exact-tokens before budgeting from it. The counting endpoint is free.`
128
+ : '';
116
129
  return {
117
130
  title: 'Turn on prompt caching for the stable prefix',
118
- detail: `${scope} At a ${hitRatePct}% hit rate, a cache read costs ${readPct}% of the input price and a write costs ${writePct}%. ${how}`,
131
+ detail: `${scope} At a ${hitRatePct}% hit rate, a cache read costs ${readPct}% of the input price and a write costs ${writePct}%. ${how}${hedge}`,
119
132
  };
120
133
  },
121
134
 
@@ -132,6 +145,7 @@ export const en: CoreMessages = {
132
145
  prefixTokens,
133
146
  totalTokens,
134
147
  mentionLowerMinimum,
148
+ couldReachMinimum,
135
149
  }) => {
136
150
  const reason = placeholder
137
151
  ? `here the first variable placeholder (${placeholder}) shows up at ~${n(prefixTokens)} tokens and only what precedes it can be cached`
@@ -142,13 +156,16 @@ export const en: CoreMessages = {
142
156
  `${modelName} needs at least ${n(minTokens)} prefix tokens to cache; ${reason}. Setting cache_control will not error, it simply will not cache.` +
143
157
  (mentionLowerMinimum
144
158
  ? ' Claude Opus 5 lowers that minimum to 512 tokens, so short prompts that miss here would cache there.'
159
+ : '') +
160
+ (couldReachMinimum
161
+ ? ' That prefix count is an estimate and it is close to the line, so the real one may already be above it — check with --exact-tokens before deciding this is not available to you. The counting endpoint is free.'
145
162
  : ''),
146
163
  };
147
164
  },
148
165
 
149
- cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder }) => ({
166
+ cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder, command }) => ({
150
167
  title: 'Move the stable instructions ahead of the first placeholder',
151
- detail: `About ~${n(staticTokensAfter)} tokens of stable content (${sharePct}% of the prompt) sit after the first variable placeholder ${placeholder}, so today they never get cached. Reorder the template — fixed instructions and context first, placeholders last and that content starts being read from cache at 10% of the price. Check that reordering does not change what the prompt asks for.`,
168
+ detail: `About ~${n(staticTokensAfter)} tokens of stable content (${sharePct}% of the prompt) sit after the first variable placeholder ${placeholder}, so today they never get cached. Fixed instructions and context first, placeholders last, and that content starts being read from cache at 10% of the price. Run \`${command}\` to attempt it: whole blocks only, and it refuses to move anything that refers back to earlier text. Read the diff order carries meaning, and "summarise the text above" is nonsense in front of the text it points at.`,
152
169
  }),
153
170
 
154
171
  batchApi: () => ({
package/src/i18n/es.ts CHANGED
@@ -91,9 +91,18 @@ export const es: CoreMessages = {
91
91
  },
92
92
 
93
93
  advisories: {
94
- contextOverflow: ({ tokens, modelName, contextWindow }) => ({
95
- title: 'El prompt no cabe en la ventana de contexto',
96
- detail: `El prompt optimizado ocupa ~${n(tokens)} tokens y ${modelName} admite ${n(contextWindow)}. La llamada fallará: divide el contenido o cambia a un modelo con ventana mayor.`,
94
+ contextOverflow: ({ tokens, modelName, contextWindow, uncertain }) => ({
95
+ title: uncertain
96
+ ? 'El prompt probablemente no cabe en la ventana de contexto'
97
+ : 'El prompt no cabe en la ventana de contexto',
98
+ detail: uncertain
99
+ ? `El prompt optimizado ocupa ~${n(tokens)} tokens frente a los ${n(contextWindow)} de ${modelName}. Ese recuento es una estimación y está cerca del límite, así que la llamada fallará probablemente, pero puede que no —confírmalo con --exact-tokens antes de reescribir nada. El endpoint de conteo es gratis. Si de verdad se pasa, divide el contenido o cambia a un modelo con ventana mayor.`
100
+ : `El prompt optimizado ocupa ~${n(tokens)} tokens y ${modelName} admite ${n(contextWindow)}. La llamada fallará: divide el contenido o cambia a un modelo con ventana mayor.`,
101
+ }),
102
+
103
+ contextNearLimit: ({ tokens, modelName, contextWindow }) => ({
104
+ title: 'El prompt puede no caber en la ventana de contexto',
105
+ detail: `El prompt optimizado ocupa ~${n(tokens)} tokens frente a los ${n(contextWindow)} de ${modelName}, así que cabe —pero ese recuento es una estimación y su margen de error se pasa de la ventana, así que el prompt real puede no caber. Una llamada que excede la ventana falla del todo en lugar de degradarse, y nada más aquí avisa de eso. Confírmalo con --exact-tokens; el endpoint de conteo es gratis.`,
97
106
  }),
98
107
 
99
108
  promptCaching: ({
@@ -106,6 +115,7 @@ export const es: CoreMessages = {
106
115
  readPct,
107
116
  writePct,
108
117
  explicit,
118
+ nearMinimum,
109
119
  }) => {
110
120
  const scope = placeholder
111
121
  ? `El prefijo estable —lo anterior al primer marcador ${placeholder}— son ~${n(prefixTokens)} de los ${n(totalTokens)} tokens del prompt, y supera el mínimo cacheable de ${n(minTokens)} de ${modelName}.`
@@ -113,9 +123,12 @@ export const es: CoreMessages = {
113
123
  const how = explicit
114
124
  ? 'Coloca el marcador de caché al final del prefijo estable: cualquier byte que cambie antes del corte invalida todo lo que va detrás.'
115
125
  : `${modelName} cachea automáticamente por encima de su mínimo, así que no hay nada que activar; pero la regla es la misma: cualquier byte que cambie antes del corte invalida todo lo que va detrás.`;
126
+ const hedge = nearMinimum
127
+ ? ` Un aviso sobre la cifra: ese recuento del prefijo es una estimación y está cerca del límite, así que el real puede quedar por debajo del mínimo de ${n(minTokens)} tokens —y entonces no se cachea nada y este ahorro no existe. Confírmalo con --exact-tokens antes de presupuestar sobre él. El endpoint de conteo es gratis.`
128
+ : '';
116
129
  return {
117
130
  title: 'Activa prompt caching en el prefijo estable',
118
- detail: `${scope} Con una tasa de acierto del ${hitRatePct}%, la lectura de caché cuesta un ${readPct}% del precio de entrada y la escritura un ${writePct}%. ${how}`,
131
+ detail: `${scope} Con una tasa de acierto del ${hitRatePct}%, la lectura de caché cuesta un ${readPct}% del precio de entrada y la escritura un ${writePct}%. ${how}${hedge}`,
119
132
  };
120
133
  },
121
134
 
@@ -132,6 +145,7 @@ export const es: CoreMessages = {
132
145
  prefixTokens,
133
146
  totalTokens,
134
147
  mentionLowerMinimum,
148
+ couldReachMinimum,
135
149
  }) => {
136
150
  const reason = placeholder
137
151
  ? `aquí el primer marcador variable (${placeholder}) aparece a los ~${n(prefixTokens)} tokens y solo lo anterior puede cachearse`
@@ -142,13 +156,16 @@ export const es: CoreMessages = {
142
156
  `${modelName} necesita al menos ${n(minTokens)} tokens de prefijo para cachear; ${reason}. Marcar cache_control no dará error, simplemente no cacheará.` +
143
157
  (mentionLowerMinimum
144
158
  ? ' Claude Opus 5 baja ese mínimo a 512 tokens, así que prompts cortos que aquí no cachean, allí sí.'
159
+ : '') +
160
+ (couldReachMinimum
161
+ ? ' Ese recuento del prefijo es una estimación y está cerca del límite, así que el real puede estar ya por encima: compruébalo con --exact-tokens antes de dar esto por perdido. El endpoint de conteo es gratis.'
145
162
  : ''),
146
163
  };
147
164
  },
148
165
 
149
- cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder }) => ({
166
+ cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder, command }) => ({
150
167
  title: 'Mueve las instrucciones estables antes del primer marcador',
151
- detail: `Unos ~${n(staticTokensAfter)} tokens de contenido estable (el ${sharePct}% del prompt) están después del primer marcador variable ${placeholder}, así que hoy no se cachean nunca. Reordena la plantilla —instrucciones y contexto fijos primero, marcadores al final y ese contenido pasa a leerse de caché al 10% del precio. Revisa que la reordenación no cambie el sentido del prompt.`,
168
+ detail: `Unos ~${n(staticTokensAfter)} tokens de contenido estable (el ${sharePct}% del prompt) están después del primer marcador variable ${placeholder}, así que hoy no se cachean nunca. Instrucciones y contexto fijos primero, marcadores al final, y ese contenido empieza a leerse de caché al 10% del precio. Ejecuta \`${command}\` para intentarlo: solo mueve bloques completos y se niega a mover cualquiera que se refiera a texto anterior. Lee el diff —el orden significa algo, y «resume el texto de arriba» no tiene sentido delante del texto al que apunta.`,
152
169
  }),
153
170
 
154
171
  batchApi: () => ({
package/src/i18n/types.ts CHANGED
@@ -51,6 +51,17 @@ export interface RuleCopy {
51
51
  // --------------------------------------------------------------------------
52
52
 
53
53
  export interface ContextOverflowParams {
54
+ /**
55
+ * The count is an estimate and its band reaches back under the window, so
56
+ * "the call will fail" is a prediction rather than a fact.
57
+ */
58
+ uncertain: boolean;
59
+ tokens: number;
60
+ modelName: string;
61
+ contextWindow: number;
62
+ }
63
+
64
+ export interface ContextNearLimitParams {
54
65
  tokens: number;
55
66
  modelName: string;
56
67
  contextWindow: number;
@@ -60,6 +71,20 @@ export interface PromptCachingParams {
60
71
  /** First template placeholder, or `null` when the prompt has none. */
61
72
  placeholder: string | null;
62
73
  prefixTokens: number;
74
+ /**
75
+ * The prefix is an estimate and the band reaches below the minimum, so the
76
+ * saving may not be collectable at all.
77
+ *
78
+ * The mirror of `BelowCacheMinimumParams.couldReachMinimum`, and the asymmetry
79
+ * was a real gap: that one hedged an estimate landing just *under* a hard
80
+ * threshold, while this one promised money on an estimate landing just *over*
81
+ * it. With a ±10% band an estimated 528-token prefix can truly be 475, in which
82
+ * case nothing caches and the figure beside this advisory is uncollectable.
83
+ *
84
+ * The cautionary direction matters more than the encouraging one, because this
85
+ * is the side with a dollar sign attached.
86
+ */
87
+ nearMinimum: boolean;
63
88
  totalTokens: number;
64
89
  minTokens: number;
65
90
  modelName: string;
@@ -88,12 +113,24 @@ export interface BelowCacheMinimumParams {
88
113
  totalTokens: number;
89
114
  /** Whether to point at Claude Opus 5's lower 512-token minimum. */
90
115
  mentionLowerMinimum: boolean;
116
+ /**
117
+ * The prefix is *estimated* and close enough to the minimum that the real
118
+ * count could be above it.
119
+ *
120
+ * Without this the advisory asserts "caching will not work here" from a number
121
+ * measured to ±10%, and on a prefix near the threshold that is not an imprecise
122
+ * figure — it is wrong advice, and it costs the reader the largest saving
123
+ * Trazum offers.
124
+ */
125
+ couldReachMinimum: boolean;
91
126
  }
92
127
 
93
128
  export interface CachePrefixReorderParams {
94
129
  staticTokensAfter: number;
95
130
  sharePct: number;
96
131
  placeholder: string;
132
+ /** The command that attempts it, because Trazum can do this itself. */
133
+ command: string;
97
134
  }
98
135
 
99
136
  export interface ModelDowngradeParams {
@@ -183,6 +220,7 @@ export interface CoreMessages {
183
220
  suggest: SuggestMessages;
184
221
  advisories: {
185
222
  contextOverflow(p: ContextOverflowParams): LocalizedMessage;
223
+ contextNearLimit(p: ContextNearLimitParams): LocalizedMessage;
186
224
  promptCaching(p: PromptCachingParams): LocalizedMessage;
187
225
  promptCachingNotWorthIt(): LocalizedMessage;
188
226
  belowCacheMinimum(p: BelowCacheMinimumParams): LocalizedMessage;
package/src/index.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  export * from './types.js';
2
- export { estimateTokens, countTokensAnthropic } from './tokenizer.js';
2
+ export { ESTIMATE_ERROR_BAND_PCT, estimateTokens, countTokensAnthropic } from './tokenizer.js';
3
+ export {
4
+ UNLABELLED,
5
+ cacheHitRate,
6
+ parseUsageLine,
7
+ profileUsage,
8
+ sharesOf,
9
+ } from './usage.js';
10
+ export type {
11
+ UsageProfileOptions,
12
+ UsageBreakdown,
13
+ UsageProfileReport,
14
+ UsageRecord,
15
+ UsageShares,
16
+ } from './usage.js';
17
+ export { DETECTABLE_LANGUAGES, detectTextLanguage } from './language.js';
3
18
  export { countSentences, profilePrompt } from './profile.js';
4
19
  export { PHRASE_LANGUAGES } from './phrases.js';
5
20
  export { toPromptfoo } from './promptfoo.js';
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Which language a prompt is written in, to the extent it can be told cheaply.
3
+ *
4
+ * **Why the estimator needs this at all.** Measured against the counting
5
+ * endpoint, `estimateTokens` is accurate on English (+1.0%) and badly low on every
6
+ * other Latin language: German -37.3%, Spanish -22.9%, French -15.1%. Characters
7
+ * per token says why — English 3.44, French 2.66, Spanish 2.53, German 2.02 —
8
+ * while the estimator applied one divisor to all of them. Non-English text is
9
+ * thinner in the merge table, so the same number of characters costs more tokens.
10
+ *
11
+ * **Accents are not the signal.** That was the first hypothesis and it was tested
12
+ * and killed: a Spanish sample with zero accented characters comes out at -22.9%,
13
+ * against -22.1% for accented Spanish. Diacritics correlate with non-English text
14
+ * in a corpus and not in a prompt, and weighting them moved the figure by three
15
+ * points. What separates these languages is which words they are made of.
16
+ *
17
+ * So this counts function words — the shortest, commonest, most language-specific
18
+ * tokens there are. `the of and to` against `der die und ist` against
19
+ * `que de la en`.
20
+ *
21
+ * **It answers `null` when unsure, and that is the important part.** A prompt is
22
+ * not an essay: it can be three lines, or English instructions wrapped around a
23
+ * Spanish example, or a JSON schema with no prose at all. Guessing on those would
24
+ * apply a language's divisor to text that is not in that language, which is how a
25
+ * fix for one case becomes a regression for four. `null` means "use the default",
26
+ * and the default is the English-calibrated behaviour this has always had.
27
+ */
28
+
29
+ /**
30
+ * Function words per language, lower-cased, matched whole.
31
+ *
32
+ * Chosen for frequency and for *not overlapping*, which matters more: `de` is
33
+ * frequent in Spanish, French and Portuguese, so it earns nothing and is a
34
+ * liability. Each list is words that are common in its own language and rare in
35
+ * the others, which is what lets a small count decide.
36
+ *
37
+ * `en` is included even though it is the default, because the margin rule needs to
38
+ * know when English *won*: a Spanish prompt quoting an English sentence should not
39
+ * become Spanish on two hits.
40
+ */
41
+ const FUNCTION_WORDS: Readonly<Record<string, readonly string[]>> = {
42
+ en: ['the', 'and', 'of', 'to', 'is', 'that', 'with', 'for', 'you', 'this', 'are', 'not'],
43
+ es: ['que', 'los', 'las', 'del', 'una', 'para', 'con', 'como', 'pero', 'sus', 'este', 'siempre'],
44
+ fr: ['les', 'des', 'une', 'dans', 'pour', 'avec', 'vous', 'est', 'sur', 'aux', 'cette', 'toujours'],
45
+ de: ['der', 'die', 'das', 'und', 'nicht', 'sie', 'ist', 'mit', 'auf', 'einen', 'einer', 'immer'],
46
+ pt: ['que', 'dos', 'das', 'uma', 'para', 'com', 'como', 'não', 'seu', 'este', 'pelo', 'sempre'],
47
+ /**
48
+ * Rebuilt once, because half of it was Spanish. `per con del una sempre` are as
49
+ * common in Spanish as in Italian, so they earned nothing and cost the margin
50
+ * rule its answer: an Italian code-review prompt scored a tie and came back
51
+ * `null`, fell through to the English divisor, and measured -21.9%. These are
52
+ * words Italian has and Spanish does not.
53
+ */
54
+ it: ['il', 'della', 'nella', 'nel', 'che', 'quando', 'senza', 'gli', 'delle', 'degli', 'più', 'anche'],
55
+ nl: ['het', 'een', 'van', 'niet', 'zijn', 'met', 'voor', 'dat', 'aan', 'deze', 'wordt', 'altijd'],
56
+ };
57
+
58
+ /** Languages this can name. Exported so callers can key a table off it. */
59
+ export const DETECTABLE_LANGUAGES: readonly string[] = Object.keys(FUNCTION_WORDS);
60
+
61
+ /**
62
+ * How many hits the winner needs, absolutely and relative to the runner-up.
63
+ *
64
+ * Both bars exist for the same reason and neither is enough alone. The absolute
65
+ * one stops a three-line prompt being classified on a single word. The ratio stops
66
+ * a document that is genuinely mixed — English instructions around a Spanish
67
+ * example — from being called whichever language happened to appear once more.
68
+ *
69
+ * Deliberately cautious in the direction of `null`. A wrong language costs
70
+ * accuracy on text that was fine before; `null` costs only the improvement.
71
+ */
72
+ const MIN_HITS = 4;
73
+ const MIN_RATIO = 1.6;
74
+
75
+ /**
76
+ * The language of `text`, or `null` when no answer is safe.
77
+ *
78
+ * Case-insensitive, whole words only, and it stops reading after a bounded
79
+ * prefix: a prompt can be a megabyte, the answer does not get better after a few
80
+ * thousand words, and this runs inside `estimateTokens` on every call.
81
+ */
82
+ export function detectTextLanguage(text: string): string | null {
83
+ if (!text) return null;
84
+
85
+ // Bounded so this stays cheap on a large prompt. Function words are frequent
86
+ // enough that a prefix this size decides anything a whole document would.
87
+ const sample = text.length > 20_000 ? text.slice(0, 20_000) : text;
88
+
89
+ const words = sample.toLowerCase().match(/[\p{L}]+/gu);
90
+ if (!words || words.length < MIN_HITS * 2) return null;
91
+
92
+ const seen = new Set(words);
93
+ const scores: Array<[string, number]> = [];
94
+ for (const [language, list] of Object.entries(FUNCTION_WORDS)) {
95
+ let hits = 0;
96
+ // Distinct words rather than occurrences: a prompt that repeats "the" forty
97
+ // times is not more English than one that uses forty different English words,
98
+ // and counting occurrences lets one hammered word decide.
99
+ for (const word of list) if (seen.has(word)) hits++;
100
+ scores.push([language, hits]);
101
+ }
102
+
103
+ scores.sort((a, b) => b[1] - a[1]);
104
+ const [best, bestHits] = scores[0]!;
105
+ const runnerUp = scores[1]?.[1] ?? 0;
106
+
107
+ if (bestHits < MIN_HITS) return null;
108
+ // A runner-up of zero is a clear win; guard the division rather than special-case.
109
+ if (runnerUp > 0 && bestHits / runnerUp < MIN_RATIO) return null;
110
+
111
+ return best;
112
+ }