@trazum/core 1.9.0 → 1.25.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 (108) hide show
  1. package/README.md +12 -5
  2. package/dist/advisories.d.ts.map +1 -1
  3. package/dist/advisories.js +94 -4
  4. package/dist/advisories.js.map +1 -1
  5. package/dist/against.d.ts +32 -0
  6. package/dist/against.d.ts.map +1 -0
  7. package/dist/against.js +34 -0
  8. package/dist/against.js.map +1 -0
  9. package/dist/config-schema.d.ts +42 -1
  10. package/dist/config-schema.d.ts.map +1 -1
  11. package/dist/config-schema.js +61 -0
  12. package/dist/config-schema.js.map +1 -1
  13. package/dist/conversation.d.ts +121 -0
  14. package/dist/conversation.d.ts.map +1 -0
  15. package/dist/conversation.js +157 -0
  16. package/dist/conversation.js.map +1 -0
  17. package/dist/csv.d.ts +61 -0
  18. package/dist/csv.d.ts.map +1 -0
  19. package/dist/csv.js +149 -0
  20. package/dist/csv.js.map +1 -0
  21. package/dist/evaluate.d.ts +24 -0
  22. package/dist/evaluate.d.ts.map +1 -1
  23. package/dist/evaluate.js +5 -2
  24. package/dist/evaluate.js.map +1 -1
  25. package/dist/i18n/en.d.ts.map +1 -1
  26. package/dist/i18n/en.js +18 -7
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/es.d.ts.map +1 -1
  29. package/dist/i18n/es.js +18 -7
  30. package/dist/i18n/es.js.map +1 -1
  31. package/dist/i18n/types.d.ts +28 -1
  32. package/dist/i18n/types.d.ts.map +1 -1
  33. package/dist/index.d.ts +25 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +28 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/input-shape.d.ts +104 -0
  38. package/dist/input-shape.d.ts.map +1 -0
  39. package/dist/input-shape.js +132 -0
  40. package/dist/input-shape.js.map +1 -0
  41. package/dist/levers.d.ts +151 -0
  42. package/dist/levers.d.ts.map +1 -0
  43. package/dist/levers.js +160 -0
  44. package/dist/levers.js.map +1 -0
  45. package/dist/node.d.ts +1 -1
  46. package/dist/node.d.ts.map +1 -1
  47. package/dist/output-shape.d.ts +96 -0
  48. package/dist/output-shape.d.ts.map +1 -0
  49. package/dist/output-shape.js +145 -0
  50. package/dist/output-shape.js.map +1 -0
  51. package/dist/pricing-overlay.d.ts +1 -1
  52. package/dist/pricing-overlay.d.ts.map +1 -1
  53. package/dist/pricing-overlay.js +46 -0
  54. package/dist/pricing-overlay.js.map +1 -1
  55. package/dist/repeats.d.ts +75 -0
  56. package/dist/repeats.d.ts.map +1 -0
  57. package/dist/repeats.js +82 -0
  58. package/dist/repeats.js.map +1 -0
  59. package/dist/reprice.d.ts +143 -0
  60. package/dist/reprice.d.ts.map +1 -0
  61. package/dist/reprice.js +82 -0
  62. package/dist/reprice.js.map +1 -0
  63. package/dist/session-cost.d.ts +70 -0
  64. package/dist/session-cost.d.ts.map +1 -0
  65. package/dist/session-cost.js +90 -0
  66. package/dist/session-cost.js.map +1 -0
  67. package/dist/session-ledger.d.ts +77 -0
  68. package/dist/session-ledger.d.ts.map +1 -0
  69. package/dist/session-ledger.js +99 -0
  70. package/dist/session-ledger.js.map +1 -0
  71. package/dist/tokenizer.d.ts +33 -26
  72. package/dist/tokenizer.d.ts.map +1 -1
  73. package/dist/tokenizer.js +95 -34
  74. package/dist/tokenizer.js.map +1 -1
  75. package/dist/ttl-fit.d.ts +103 -0
  76. package/dist/ttl-fit.d.ts.map +1 -0
  77. package/dist/ttl-fit.js +184 -0
  78. package/dist/ttl-fit.js.map +1 -0
  79. package/dist/types.d.ts +1 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/usage.d.ts +637 -0
  82. package/dist/usage.d.ts.map +1 -0
  83. package/dist/usage.js +634 -0
  84. package/dist/usage.js.map +1 -0
  85. package/package.json +1 -1
  86. package/src/advisories.ts +96 -4
  87. package/src/against.ts +48 -0
  88. package/src/config-schema.ts +106 -0
  89. package/src/conversation.ts +305 -0
  90. package/src/csv.ts +184 -0
  91. package/src/evaluate.ts +33 -3
  92. package/src/i18n/en.ts +19 -6
  93. package/src/i18n/es.ts +19 -6
  94. package/src/i18n/types.ts +29 -1
  95. package/src/index.ts +65 -1
  96. package/src/input-shape.ts +259 -0
  97. package/src/levers.ts +331 -0
  98. package/src/node.ts +1 -1
  99. package/src/output-shape.ts +254 -0
  100. package/src/pricing-overlay.ts +52 -1
  101. package/src/repeats.ts +166 -0
  102. package/src/reprice.ts +227 -0
  103. package/src/session-cost.ts +170 -0
  104. package/src/session-ledger.ts +189 -0
  105. package/src/tokenizer.ts +93 -30
  106. package/src/ttl-fit.ts +251 -0
  107. package/src/types.ts +1 -0
  108. package/src/usage.ts +1267 -0
package/src/csv.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { UNLABELLED } from './usage.js';
2
+ import type { UsageProfileReport } from './usage.js';
3
+
4
+ /**
5
+ * The profile as a spreadsheet.
6
+ *
7
+ * ## Why a file format is a feature
8
+ *
9
+ * The terminal report is read once and closed. The people who decide what a
10
+ * workload is allowed to cost live in spreadsheets, and handing them a
11
+ * screenshot of a terminal is how a finding stops at the person who ran the
12
+ * command. `--json` is for machines; this is for the pivot table that gets
13
+ * shown to whoever signs off the bill.
14
+ *
15
+ * ## One row per label and model, and no total row
16
+ *
17
+ * `byLabelAndModel` is the grouping a decision is actually made at — routing
18
+ * `classify` to a cheaper model is a question about one label's calls to one
19
+ * model — so it is the grain of the file.
20
+ *
21
+ * **There is deliberately no TOTAL row.** A total inside a data file is the
22
+ * oldest spreadsheet trap there is: somebody sums the column, the total row is
23
+ * included, and every figure downstream is exactly twice what it should be.
24
+ * The sum of this file is the bill, and a spreadsheet can compute it.
25
+ *
26
+ * ## Unpriced models get empty cells, never zeros
27
+ *
28
+ * A model the catalogue does not know has real tokens and unknown dollars.
29
+ * Writing `0` there would be a claim — that those calls were free — and it
30
+ * would survive into every chart built on the file. An empty cell is the
31
+ * absence it actually is, and spreadsheets already know how to skip one.
32
+ */
33
+
34
+ /** Columns, in order. Exported so a test can pin the header rather than a string. */
35
+ export const PROFILE_CSV_COLUMNS = [
36
+ 'label',
37
+ 'model',
38
+ 'calls',
39
+ 'input_tokens',
40
+ 'cache_read_tokens',
41
+ 'cache_write_tokens',
42
+ 'output_tokens',
43
+ 'input_usd',
44
+ 'cache_read_usd',
45
+ 'cache_write_usd',
46
+ 'output_usd',
47
+ 'total_usd',
48
+ ] as const;
49
+
50
+ /**
51
+ * One CSV field, RFC 4180.
52
+ *
53
+ * Labels are arbitrary strings out of somebody's log: a label containing a
54
+ * comma would shift every column after it, and one containing a quote would
55
+ * break the row it sits in. Both are quoted here rather than sanitised,
56
+ * because changing the label would make the file disagree with every other
57
+ * rendering about what the workload is called.
58
+ *
59
+ * A leading `=`, `+`, `-` or `@` is prefixed with an apostrophe: those are
60
+ * how a spreadsheet is told a cell is a formula, and a label out of a log is
61
+ * data. This is the one place a value is altered, and it is altered to stop
62
+ * a log from executing anything when the file is opened.
63
+ */
64
+ function field(value: string): string {
65
+ const guarded = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
66
+ return /[",\n\r]/.test(guarded) ? `"${guarded.replace(/"/g, '""')}"` : guarded;
67
+ }
68
+
69
+ /** A dollar figure with enough places to survive being summed. */
70
+ const usd = (value: number): string => value.toFixed(6);
71
+
72
+ export interface ProfileCsvOptions {
73
+ /** What to call the bucket for calls carrying no label. */
74
+ unlabelled: string;
75
+ /**
76
+ * Which table to write.
77
+ *
78
+ * `slice` is one row per label and model — the grain a routing or budget
79
+ * decision is made at. `day` and `hour` are the time series, which is what
80
+ * a spreadsheet gets asked to chart; keeping them behind a choice rather
81
+ * than in extra columns means every file has one row shape, and a
82
+ * spreadsheet that has to filter before it can sum is a spreadsheet
83
+ * somebody sums wrong.
84
+ */
85
+ shape?: ProfileCsvShape;
86
+ }
87
+
88
+ export type ProfileCsvShape = 'slice' | 'day' | 'hour';
89
+
90
+ /** Columns for the per-day series. */
91
+ export const PROFILE_CSV_DAY_COLUMNS = ['day', 'usd', 'calls', 'top_label', 'top_label_usd'] as const;
92
+
93
+ /** Columns for the per-hour-of-UTC-day series. */
94
+ export const PROFILE_CSV_HOUR_COLUMNS = ['hour_utc', 'usd', 'calls'] as const;
95
+
96
+ /**
97
+ * The report as CSV text, one row per label and model.
98
+ *
99
+ * Rows arrive in the report's own order — largest bill first — because a
100
+ * spreadsheet can re-sort and a reader opening the file should see the
101
+ * expensive workload at the top either way.
102
+ */
103
+ export function profileToCsv(report: UsageProfileReport, options: ProfileCsvOptions): string {
104
+ if (options.shape === 'day') {
105
+ const rows: string[] = [PROFILE_CSV_DAY_COLUMNS.join(',')];
106
+ for (const day of report.spendByDay) {
107
+ rows.push(
108
+ [
109
+ day.day,
110
+ usd(day.usd),
111
+ String(day.calls),
112
+ // A day whose calls carried no label at all has no top label, and an
113
+ // empty cell is that absence. Naming the unlabelled bucket here
114
+ // would claim a label the log never carried.
115
+ day.topLabel === null
116
+ ? ''
117
+ : field(day.topLabel === UNLABELLED ? options.unlabelled : day.topLabel),
118
+ day.topLabel === null ? '' : usd(day.topLabelUsd),
119
+ ].join(','),
120
+ );
121
+ }
122
+ return `${rows.join('\n')}\n`;
123
+ }
124
+
125
+ if (options.shape === 'hour') {
126
+ const rows: string[] = [PROFILE_CSV_HOUR_COLUMNS.join(',')];
127
+ for (const hour of report.spendByHour) {
128
+ rows.push([String(hour.hour), usd(hour.usd), String(hour.calls)].join(','));
129
+ }
130
+ return `${rows.join('\n')}\n`;
131
+ }
132
+
133
+ const rows: string[] = [PROFILE_CSV_COLUMNS.join(',')];
134
+
135
+ for (const { label, model, breakdown } of report.byLabelAndModel) {
136
+ rows.push(
137
+ [
138
+ field(label === UNLABELLED ? options.unlabelled : label),
139
+ field(model),
140
+ String(breakdown.calls),
141
+ String(breakdown.inputTokens),
142
+ String(breakdown.cacheReadTokens),
143
+ String(breakdown.cacheWriteTokens),
144
+ String(breakdown.outputTokens),
145
+ usd(breakdown.inputUsd),
146
+ usd(breakdown.cacheReadUsd),
147
+ usd(breakdown.cacheWriteUsd),
148
+ usd(breakdown.outputUsd),
149
+ usd(breakdown.totalUsd),
150
+ ].join(','),
151
+ );
152
+ }
153
+
154
+ /**
155
+ * The unpriced calls, with their tokens and no dollars.
156
+ *
157
+ * They are absent from `byLabelAndModel` — which holds what could be priced
158
+ * — and leaving them out of the file entirely would make its token columns
159
+ * disagree with the log. `byModel` keeps them, so they are recovered from
160
+ * there, with empty dollar cells rather than zeros.
161
+ */
162
+ for (const model of report.unpricedModels) {
163
+ const row = report.byModel.find((entry) => entry.model === model);
164
+ if (!row) continue;
165
+ rows.push(
166
+ [
167
+ field(options.unlabelled),
168
+ field(model),
169
+ String(row.breakdown.calls),
170
+ String(row.breakdown.inputTokens),
171
+ String(row.breakdown.cacheReadTokens),
172
+ String(row.breakdown.cacheWriteTokens),
173
+ String(row.breakdown.outputTokens),
174
+ '',
175
+ '',
176
+ '',
177
+ '',
178
+ '',
179
+ ].join(','),
180
+ );
181
+ }
182
+
183
+ return `${rows.join('\n')}\n`;
184
+ }
package/src/evaluate.ts CHANGED
@@ -41,6 +41,16 @@ export type EvalVerdict = 'indistinguishable' | 'within-noise' | 'diverges' | 'i
41
41
  export interface EvalReport {
42
42
  provider: string;
43
43
  model: string;
44
+ /**
45
+ * The model the candidate answer came from.
46
+ *
47
+ * Equal to `model` on the ordinary comparison — same model, two prompts. It
48
+ * differs when the question is the other one: **same prompt, two models**, which
49
+ * is what a routing decision is. `profile` prices that route exactly and can say
50
+ * nothing at all about whether the cheaper model still does the job; this is the
51
+ * measurement that can.
52
+ */
53
+ candidateModel: string;
44
54
  cases: EvalCase[];
45
55
  /** Mean agreement of the original prompt with itself. The yardstick. */
46
56
  selfAgreement: number;
@@ -58,6 +68,20 @@ export interface EvaluateOptions {
58
68
  * call already paid for.
59
69
  */
60
70
  concurrency?: number;
71
+ /**
72
+ * Where the candidate answer comes from. Defaults to `provider`.
73
+ *
74
+ * This is the whole routing axis, and it needed no new yardstick. The baseline
75
+ * prompt is still run **twice on the original model** to measure that model's own
76
+ * variance, and the candidate is still judged against it — so the question
77
+ * becomes "does the cheaper model agree with the expensive one more closely than
78
+ * the expensive one agrees with itself?", which is the honest form of "is this
79
+ * route safe".
80
+ *
81
+ * A verdict built any other way would be a threshold somebody picked. This one is
82
+ * the model's own noise floor, measured on the same cases in the same run.
83
+ */
84
+ candidateProvider?: LlmProvider;
61
85
  }
62
86
 
63
87
  /**
@@ -149,9 +173,13 @@ export async function evaluate(
149
173
  options: EvaluateOptions = {},
150
174
  ): Promise<EvalReport> {
151
175
  const concurrency = Math.max(1, options.concurrency ?? 3);
176
+ const candidate = options.candidateProvider ?? provider;
152
177
 
153
- const run = (prompt: string, input: string): Promise<string> =>
154
- provider.complete({ system: fillPrompt(prompt, input), user: input });
178
+ const run = (
179
+ prompt: string,
180
+ input: string,
181
+ on: LlmProvider = provider,
182
+ ): Promise<string> => on.complete({ system: fillPrompt(prompt, input), user: input });
155
183
 
156
184
  const cases = await pooled(
157
185
  inputs.map((input) => async (): Promise<EvalCase> => {
@@ -160,7 +188,8 @@ export async function evaluate(
160
188
  // serve one from a cache and report a variance of zero.
161
189
  const baselineA = await run(originalPrompt, input);
162
190
  const baselineB = await run(originalPrompt, input);
163
- const optimized = await run(optimizedPrompt, input);
191
+ // On `candidate`, which is `provider` unless a route is being measured.
192
+ const optimized = await run(optimizedPrompt, input, candidate);
164
193
 
165
194
  return {
166
195
  input,
@@ -179,6 +208,7 @@ export async function evaluate(
179
208
  return {
180
209
  provider: provider.name,
181
210
  model: provider.model,
211
+ candidateModel: candidate.model,
182
212
  cases,
183
213
  selfAgreement,
184
214
  crossAgreement,
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
 
@@ -150,9 +163,9 @@ export const en: CoreMessages = {
150
163
  };
151
164
  },
152
165
 
153
- cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder }) => ({
166
+ cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder, command }) => ({
154
167
  title: 'Move the stable instructions ahead of the first placeholder',
155
- 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.`,
156
169
  }),
157
170
 
158
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
 
@@ -150,9 +163,9 @@ export const es: CoreMessages = {
150
163
  };
151
164
  },
152
165
 
153
- cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder }) => ({
166
+ cachePrefixReorder: ({ staticTokensAfter, sharePct, placeholder, command }) => ({
154
167
  title: 'Mueve las instrucciones estables antes del primer marcador',
155
- 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.`,
156
169
  }),
157
170
 
158
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;
@@ -93,7 +118,7 @@ export interface BelowCacheMinimumParams {
93
118
  * count could be above it.
94
119
  *
95
120
  * Without this the advisory asserts "caching will not work here" from a number
96
- * measured to ±15%, and on a prefix near the threshold that is not an imprecise
121
+ * measured to ±10%, and on a prefix near the threshold that is not an imprecise
97
122
  * figure — it is wrong advice, and it costs the reader the largest saving
98
123
  * Trazum offers.
99
124
  */
@@ -104,6 +129,8 @@ export interface CachePrefixReorderParams {
104
129
  staticTokensAfter: number;
105
130
  sharePct: number;
106
131
  placeholder: string;
132
+ /** The command that attempts it, because Trazum can do this itself. */
133
+ command: string;
107
134
  }
108
135
 
109
136
  export interface ModelDowngradeParams {
@@ -193,6 +220,7 @@ export interface CoreMessages {
193
220
  suggest: SuggestMessages;
194
221
  advisories: {
195
222
  contextOverflow(p: ContextOverflowParams): LocalizedMessage;
223
+ contextNearLimit(p: ContextNearLimitParams): LocalizedMessage;
196
224
  promptCaching(p: PromptCachingParams): LocalizedMessage;
197
225
  promptCachingNotWorthIt(): LocalizedMessage;
198
226
  belowCacheMinimum(p: BelowCacheMinimumParams): LocalizedMessage;
package/src/index.ts CHANGED
@@ -1,5 +1,69 @@
1
1
  export * from './types.js';
2
2
  export { ESTIMATE_ERROR_BAND_PCT, estimateTokens, countTokensAnthropic } from './tokenizer.js';
3
+ export {
4
+ UNLABELLED,
5
+ cacheEconomics,
6
+ cacheHitRate,
7
+ parseUsageLine,
8
+ profileUsage,
9
+ sharesOf,
10
+ } from './usage.js';
11
+ export type {
12
+ CacheEconomics,
13
+ CacheVerdict,
14
+ UsageProfileOptions,
15
+ UsageBreakdown,
16
+ UsageProfileReport,
17
+ UsageRecord,
18
+ UsageShares,
19
+ } from './usage.js';
20
+ export { conversationGrowth, createConversationTracker } from './conversation.js';
21
+ // Whether the cache TTL fits how fast the turns arrive — the mechanism behind a
22
+ // losing cache, readable only when the log carries a clock. See ttl-fit.ts.
23
+ export { TTL_1H_MS, TTL_5M_MS, cacheTtlFit, createTtlFitTracker } from './ttl-fit.js';
24
+ export type { CacheTtlFit, TtlFitOptions, TtlFitTracker, TtlFitVerdict } from './ttl-fit.js';
25
+ // Cache writes by conversations that never came back — a ceiling on waste,
26
+ // named as one, and a fact when the slice read nothing. See session-ledger.ts.
27
+ // The drivers of a change between two bills — one implementation, because the
28
+ // sign convention (positive means the bill grew) has flipped once already
29
+ // when restated by hand. See against.ts.
30
+ // The profile as a spreadsheet — one row per label and model, no total row,
31
+ // empty cells where dollars are unknown. See csv.ts.
32
+ export {
33
+ PROFILE_CSV_COLUMNS,
34
+ PROFILE_CSV_DAY_COLUMNS,
35
+ PROFILE_CSV_HOUR_COLUMNS,
36
+ profileToCsv,
37
+ } from './csv.js';
38
+ export type { ProfileCsvOptions, ProfileCsvShape } from './csv.js';
39
+ export { driversBetween } from './against.js';
40
+ export type { AgainstDriver } from './against.js';
41
+ // The same tokens at another model's rates — arithmetic, not advice, and it
42
+ // refuses to price a call the target could not have accepted. See reprice.ts.
43
+ // The shape of a call's input — the half of the bill a total could only name.
44
+ // See input-shape.ts.
45
+ export { createInputShapeTracker, inputShapes } from './input-shape.js';
46
+ export type { InputShape, InputShapeOptions, InputShapeTracker } from './input-shape.js';
47
+ // The same request sent again a moment later — a retry or a loop, named as
48
+ // the pattern it is and never as a certainty. See repeats.ts.
49
+ export { createRepeatsTracker, repeatedTurns } from './repeats.js';
50
+ export type { RepeatedTurns, RepeatsOptions, RepeatsTracker } from './repeats.js';
51
+ export { priceTokensOn, repriceProfile } from './reprice.js';
52
+ export type { OverContextSlice, RepriceReport, RepricedSlice } from './reprice.js';
53
+ export { createSessionLedgerTracker, singleTurnCacheWrites } from './session-ledger.js';
54
+ // What one conversation costs — median and p95, exact. See session-cost.ts.
55
+ export { createSessionCostTracker, sessionCostShapes } from './session-cost.js';
56
+ export type { SessionCostOptions, SessionCostShape, SessionCostTracker } from './session-cost.js';
57
+ export type {
58
+ SessionLedgerOptions,
59
+ SessionLedgerTracker,
60
+ SingleTurnCacheWrites,
61
+ } from './session-ledger.js';
62
+ export { createOutputShapeTracker, outputShapes } from './output-shape.js';
63
+ export type { OutputShape, OutputShapeOptions, OutputShapeTracker } from './output-shape.js';
64
+ export type { ConversationGrowth, ConversationOptions, ConversationTracker } from './conversation.js';
65
+ export { billLevers } from './levers.js';
66
+ export type { BillLevers, BillLeverOptions, LeverId, SliceLevers } from './levers.js';
3
67
  export { DETECTABLE_LANGUAGES, detectTextLanguage } from './language.js';
4
68
  export { countSentences, profilePrompt } from './profile.js';
5
69
  export { PHRASE_LANGUAGES } from './phrases.js';
@@ -226,4 +290,4 @@ export {
226
290
  budgetFor,
227
291
  parseConfig,
228
292
  } from './config-schema.js';
229
- export type { ResolvedBudget, TrazumConfig } from './config-schema.js';
293
+ export type { ResolvedBudget, SpendConfig, TrazumConfig } from './config-schema.js';