@trazum/core 1.9.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/dist/usage.js ADDED
@@ -0,0 +1,274 @@
1
+ import { effectivePricing, multipliersFor } from './pricing.js';
2
+ const EMPTY = () => ({
3
+ calls: 0,
4
+ inputTokens: 0,
5
+ cacheReadTokens: 0,
6
+ cacheWriteTokens: 0,
7
+ outputTokens: 0,
8
+ assumedWriteTtlCalls: 0,
9
+ inputUsd: 0,
10
+ cacheReadUsd: 0,
11
+ cacheWriteUsd: 0,
12
+ outputUsd: 0,
13
+ totalUsd: 0,
14
+ });
15
+ const OK = (value) => ({ kind: 'ok', value });
16
+ function readCount(...candidates) {
17
+ let sawCorrupt = false;
18
+ for (const value of candidates) {
19
+ if (value === undefined)
20
+ continue;
21
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
22
+ return OK(value);
23
+ // Present and unusable: a string, a null, a negative, a NaN.
24
+ sawCorrupt = true;
25
+ }
26
+ return sawCorrupt ? { kind: 'corrupt' } : { kind: 'absent' };
27
+ }
28
+ /** Zero for an absent count. Callers reject corrupt ones before reaching this. */
29
+ const valueOf = (count) => (count.kind === 'ok' ? count.value : 0);
30
+ /**
31
+ * One line of a usage log, or `null` when it is not one.
32
+ *
33
+ * Accepts the Anthropic shape and the OpenAI one, because those are the two
34
+ * things people actually have. The alternative — a Trazum-specific schema — asks
35
+ * for a transformation step before the tool will read anything, and a tool with a
36
+ * setup cost that exceeds its payoff does not get run twice.
37
+ *
38
+ * `null` in three cases, and the third is the one that was wrong:
39
+ *
40
+ * 1. Not JSON, or not an object, or no `model`.
41
+ * 2. **No** token counts at all — counting it would inflate the call count while
42
+ * contributing nothing, which lowers every per-call figure.
43
+ * 3. **Any** count present but unreadable. A field that is there and unusable is
44
+ * corruption, and a corrupt line belongs in `skippedLines` where the report
45
+ * names it, not in the totals as a silent zero.
46
+ */
47
+ export function parseUsageLine(line) {
48
+ let raw;
49
+ try {
50
+ raw = JSON.parse(line);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
56
+ return null;
57
+ const record = raw;
58
+ // Anthropic nests usage on a response; a hand-rolled log usually flattens it.
59
+ const usage = typeof record.usage === 'object' && record.usage !== null
60
+ ? record.usage
61
+ : record;
62
+ const model = typeof record.model === 'string' ? record.model : null;
63
+ if (!model)
64
+ return null;
65
+ /**
66
+ * OpenAI reports cached tokens inside `prompt_tokens_details` **and counts them
67
+ * in `prompt_tokens`**, while Anthropic reports them separately and does not.
68
+ * Subtracting in one case and not the other is the difference between a correct
69
+ * bill and one that charges the cached half twice.
70
+ */
71
+ const details = typeof usage.prompt_tokens_details === 'object' && usage.prompt_tokens_details !== null
72
+ ? usage.prompt_tokens_details
73
+ : null;
74
+ const openAiCached = details ? readCount(details.cached_tokens) : { kind: 'absent' };
75
+ /**
76
+ * Anthropic splits cache writes by time-to-live, and the two cost different
77
+ * amounts: 1.25x input for the 5-minute entry, **2x** for the 1-hour one.
78
+ *
79
+ * Reading only the flat `cache_creation_input_tokens` threw that distinction
80
+ * away and then priced everything at the cheaper rate — a 1-hour workload
81
+ * reported 37.5% under, silently, on its largest line. The split is in the log
82
+ * whenever the recording recipe in the README is followed, because it is part of
83
+ * the `usage` object the API returns.
84
+ */
85
+ const creation = typeof usage.cache_creation === 'object' && usage.cache_creation !== null
86
+ ? usage.cache_creation
87
+ : null;
88
+ const write5m = creation ? readCount(creation.ephemeral_5m_input_tokens) : { kind: 'absent' };
89
+ const write1h = creation ? readCount(creation.ephemeral_1h_input_tokens) : { kind: 'absent' };
90
+ const counts = {
91
+ input: readCount(usage.input_tokens, usage.inputTokens, usage.prompt_tokens),
92
+ output: readCount(usage.output_tokens, usage.outputTokens, usage.completion_tokens),
93
+ cacheRead: readCount(usage.cache_read_input_tokens, usage.cacheReadTokens),
94
+ cacheWrite: readCount(usage.cache_creation_input_tokens, usage.cacheWriteTokens),
95
+ openAiCached,
96
+ write5m,
97
+ write1h,
98
+ };
99
+ // Any field present and unreadable rejects the line. See `readCount`.
100
+ if (Object.values(counts).some((c) => c.kind === 'corrupt'))
101
+ return null;
102
+ // Nothing to count at all.
103
+ if (Object.values(counts).every((c) => c.kind === 'absent'))
104
+ return null;
105
+ const cached = valueOf(counts.openAiCached);
106
+ const flatWrite = valueOf(counts.cacheWrite);
107
+ const split5m = valueOf(counts.write5m);
108
+ const split1h = valueOf(counts.write1h);
109
+ const hasSplit = counts.write5m.kind === 'ok' || counts.write1h.kind === 'ok';
110
+ return {
111
+ model,
112
+ inputTokens: Math.max(0, valueOf(counts.input) - cached),
113
+ cacheReadTokens: counts.cacheRead.kind === 'ok' ? counts.cacheRead.value : cached,
114
+ /**
115
+ * The split when the log carries it, the flat number otherwise — and
116
+ * `writeTtlKnown` says which, so the report can admit that a rate was assumed
117
+ * rather than quietly choosing the cheaper one.
118
+ */
119
+ cacheWrite5mTokens: hasSplit ? split5m : flatWrite,
120
+ cacheWrite1hTokens: hasSplit ? split1h : 0,
121
+ writeTtlKnown: hasSplit || flatWrite === 0,
122
+ outputTokens: valueOf(counts.output),
123
+ label: typeof record.label === 'string' && record.label.trim() !== ''
124
+ ? record.label.trim()
125
+ : null,
126
+ };
127
+ }
128
+ /** The bucket unlabelled calls land in, named so a report can say so. */
129
+ export const UNLABELLED = 'unlabelled';
130
+ /** Token counts only. Used for both halves, because both need them. */
131
+ function countInto(into, record) {
132
+ into.calls += 1;
133
+ into.inputTokens += record.inputTokens;
134
+ into.cacheReadTokens += record.cacheReadTokens;
135
+ into.cacheWriteTokens += record.cacheWrite5mTokens + record.cacheWrite1hTokens;
136
+ if (!record.writeTtlKnown)
137
+ into.assumedWriteTtlCalls += 1;
138
+ into.outputTokens += record.outputTokens;
139
+ }
140
+ function add(into, record, catalogue, on) {
141
+ /**
142
+ * Looked up directly rather than through `modelFrom`, which **throws** on an id
143
+ * it does not know. A usage log is somebody's production traffic and will
144
+ * contain models this catalogue has never heard of — a fine-tune, a preview, a
145
+ * competitor. Throwing means one unfamiliar id destroys the whole profile;
146
+ * naming it separately means the report is honest about what it could not price
147
+ * and useful about everything else.
148
+ *
149
+ * **Priced first, counted second.** The other order was the bug: counts landed
150
+ * before the lookup could fail, so an unpriced call contributed tokens to a
151
+ * total whose dollars excluded it.
152
+ */
153
+ const model = catalogue.byId.get(record.model);
154
+ if (!model)
155
+ return false;
156
+ countInto(into, record);
157
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
158
+ const rates = multipliersFor(model);
159
+ const per = (tokens, rate) => (tokens / 1_000_000) * rate;
160
+ into.inputUsd += per(record.inputTokens, inputPerMTok);
161
+ into.cacheReadUsd += per(record.cacheReadTokens, inputPerMTok * rates.cacheRead);
162
+ /**
163
+ * Each TTL at its own rate. Anthropic charges 1.25x input for a 5-minute entry
164
+ * and 2x for a 1-hour one, and the first version applied 1.25x to both — 37.5%
165
+ * under on a 1-hour workload, on the largest line, with nothing on screen
166
+ * saying a rate had been chosen.
167
+ */
168
+ into.cacheWriteUsd += per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m);
169
+ into.cacheWriteUsd += per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
170
+ into.outputUsd += per(record.outputTokens, outputPerMTok);
171
+ into.totalUsd =
172
+ into.inputUsd + into.cacheReadUsd + into.cacheWriteUsd + into.outputUsd;
173
+ return true;
174
+ }
175
+ /**
176
+ * Reads a usage log and says where the money went.
177
+ *
178
+ * Takes the whole text rather than a stream: a usage log is measured in megabytes
179
+ * and this package imports no Node builtins, so streaming would mean an interface
180
+ * the browser build cannot satisfy. `@trazum/core/node` is where file reading
181
+ * lives, and it can chunk if it ever needs to.
182
+ */
183
+ export function profileUsage(text, options) {
184
+ const { catalogue, on = new Date() } = options;
185
+ const total = EMPTY();
186
+ const unpriced = EMPTY();
187
+ const byLabel = new Map();
188
+ const byModel = new Map();
189
+ const unpricedModels = new Set();
190
+ const skippedLines = [];
191
+ const lines = text.split('\n');
192
+ for (let i = 0; i < lines.length; i += 1) {
193
+ const line = lines[i].trim();
194
+ if (line === '')
195
+ continue;
196
+ const record = parseUsageLine(line);
197
+ if (!record) {
198
+ skippedLines.push(i + 1);
199
+ continue;
200
+ }
201
+ if (!add(total, record, catalogue, on)) {
202
+ unpricedModels.add(record.model);
203
+ countInto(unpriced, record);
204
+ // Still grouped by model, so the reader can see which unknown id is costing
205
+ // them attention — but with zero dollars, which the grouping makes obvious.
206
+ if (!byModel.has(record.model))
207
+ byModel.set(record.model, EMPTY());
208
+ countInto(byModel.get(record.model), record);
209
+ continue;
210
+ }
211
+ const labelKey = record.label ?? UNLABELLED;
212
+ if (!byLabel.has(labelKey))
213
+ byLabel.set(labelKey, EMPTY());
214
+ add(byLabel.get(labelKey), record, catalogue, on);
215
+ if (!byModel.has(record.model))
216
+ byModel.set(record.model, EMPTY());
217
+ add(byModel.get(record.model), record, catalogue, on);
218
+ }
219
+ const sorted = (map, key) => [...map.entries()]
220
+ .sort((a, b) => b[1].totalUsd - a[1].totalUsd || a[0].localeCompare(b[0]))
221
+ .map(([name, breakdown]) => ({ [key]: name, breakdown }));
222
+ return {
223
+ total,
224
+ byLabel: sorted(byLabel, 'label'),
225
+ byModel: sorted(byModel, 'model'),
226
+ unpricedModels: [...unpricedModels].sort(),
227
+ unpriced,
228
+ skippedLines,
229
+ };
230
+ }
231
+ /**
232
+ * What share of the bill each part is.
233
+ *
234
+ * The point of the whole module in one function: a caller can print "output is
235
+ * 87% of this" without doing arithmetic that would drift from the arithmetic
236
+ * here.
237
+ *
238
+ * All zeroes when nothing was spent, rather than `NaN`. A profile of an empty log
239
+ * is a legitimate result — no calls yet — and a report full of `NaN%` is a bug
240
+ * report from somebody who did nothing wrong.
241
+ */
242
+ export function sharesOf(breakdown) {
243
+ const { totalUsd } = breakdown;
244
+ if (totalUsd <= 0)
245
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
246
+ return {
247
+ input: breakdown.inputUsd / totalUsd,
248
+ cacheRead: breakdown.cacheReadUsd / totalUsd,
249
+ cacheWrite: breakdown.cacheWriteUsd / totalUsd,
250
+ output: breakdown.outputUsd / totalUsd,
251
+ };
252
+ }
253
+ /**
254
+ * How much of the input that could have been cached was.
255
+ *
256
+ * `null` when nothing was cacheable-looking at all — no reads and no writes —
257
+ * because a hit rate over zero attempts is not zero, it is undefined, and
258
+ * printing "0% cache hit rate" for somebody who never turned caching on is a
259
+ * finding about nothing.
260
+ *
261
+ * Reads against reads-plus-full-price-input, deliberately. Cache *writes* are
262
+ * excluded from the denominator: a write is the cost of establishing an entry,
263
+ * not a missed read, and counting it as a miss makes a healthy cache look broken
264
+ * on the day it warms.
265
+ */
266
+ export function cacheHitRate(breakdown) {
267
+ const attempts = breakdown.cacheReadTokens + breakdown.inputTokens;
268
+ if (breakdown.cacheReadTokens === 0 && breakdown.cacheWriteTokens === 0)
269
+ return null;
270
+ if (attempts === 0)
271
+ return null;
272
+ return breakdown.cacheReadTokens / attempts;
273
+ }
274
+ //# sourceMappingURL=usage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage.js","sourceRoot":"","sources":["../src/usage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AA+JhE,MAAM,KAAK,GAAG,GAAmB,EAAE,CAAC,CAAC;IACnC,KAAK,EAAE,CAAC;IACR,WAAW,EAAE,CAAC;IACd,eAAe,EAAE,CAAC;IAClB,gBAAgB,EAAE,CAAC;IACnB,YAAY,EAAE,CAAC;IACf,oBAAoB,EAAE,CAAC;IACvB,QAAQ,EAAE,CAAC;IACX,YAAY,EAAE,CAAC;IACf,aAAa,EAAE,CAAC;IAChB,SAAS,EAAE,CAAC;IACZ,QAAQ,EAAE,CAAC;CACZ,CAAC,CAAC;AAsBH,MAAM,EAAE,GAAG,CAAC,KAAa,EAAS,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAE7D,SAAS,SAAS,CAAC,GAAG,UAAqB;IACzC,IAAI,UAAU,GAAG,KAAK,CAAC;IACvB,KAAK,MAAM,KAAK,IAAI,UAAU,EAAE,CAAC;QAC/B,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;YAAE,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;QACxF,6DAA6D;QAC7D,UAAU,GAAG,IAAI,CAAC;IACpB,CAAC;IACD,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAC/D,CAAC;AAED,kFAAkF;AAClF,MAAM,OAAO,GAAG,CAAC,KAAY,EAAU,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAElF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,IAAI,GAAY,CAAC;IACjB,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAE/E,MAAM,MAAM,GAAG,GAA8B,CAAC;IAC9C,8EAA8E;IAC9E,MAAM,KAAK,GACT,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,KAAK,IAAI;QACvD,CAAC,CAAE,MAAM,CAAC,KAAiC;QAC3C,CAAC,CAAC,MAAM,CAAC;IAEb,MAAM,KAAK,GAAG,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IACrE,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IAExB;;;;;OAKG;IACH,MAAM,OAAO,GACX,OAAO,KAAK,CAAC,qBAAqB,KAAK,QAAQ,IAAI,KAAK,CAAC,qBAAqB,KAAK,IAAI;QACrF,CAAC,CAAE,KAAK,CAAC,qBAAiD;QAC1D,CAAC,CAAC,IAAI,CAAC;IACX,MAAM,YAAY,GAAG,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAE,EAAE,IAAI,EAAE,QAAQ,EAAY,CAAC;IAEhG;;;;;;;;;OASG;IACH,MAAM,QAAQ,GACZ,OAAO,KAAK,CAAC,cAAc,KAAK,QAAQ,IAAI,KAAK,CAAC,cAAc,KAAK,IAAI;QACvE,CAAC,CAAE,KAAK,CAAC,cAA0C;QACnD,CAAC,CAAC,IAAI,CAAC;IACX,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAE,EAAE,IAAI,EAAE,QAAQ,EAAY,CAAC;IACzG,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAE,EAAE,IAAI,EAAE,QAAQ,EAAY,CAAC;IAEzG,MAAM,MAAM,GAA0B;QACpC,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC,YAAY,EAAE,KAAK,CAAC,WAAW,EAAE,KAAK,CAAC,aAAa,CAAC;QAC5E,MAAM,EAAE,SAAS,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,YAAY,EAAE,KAAK,CAAC,iBAAiB,CAAC;QACnF,SAAS,EAAE,SAAS,CAAC,KAAK,CAAC,uBAAuB,EAAE,KAAK,CAAC,eAAe,CAAC;QAC1E,UAAU,EAAE,SAAS,CAAC,KAAK,CAAC,2BAA2B,EAAE,KAAK,CAAC,gBAAgB,CAAC;QAChF,YAAY;QACZ,OAAO;QACP,OAAO;KACR,CAAC;IAEF,sEAAsE;IACtE,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC;IACzE,2BAA2B;IAC3B,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,YAAa,CAAC,CAAC;IAC7C,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,UAAW,CAAC,CAAC;IAC9C,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,OAAQ,CAAC,CAAC;IACzC,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,OAAQ,CAAC,CAAC;IACzC,MAAM,QAAQ,GAAG,MAAM,CAAC,OAAQ,CAAC,IAAI,KAAK,IAAI,IAAI,MAAM,CAAC,OAAQ,CAAC,IAAI,KAAK,IAAI,CAAC;IAEhF,OAAO;QACL,KAAK;QACL,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,KAAM,CAAC,GAAG,MAAM,CAAC;QACzD,eAAe,EAAE,MAAM,CAAC,SAAU,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAU,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM;QACnF;;;;WAIG;QACH,kBAAkB,EAAE,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS;QAClD,kBAAkB,EAAE,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;QAC1C,aAAa,EAAE,QAAQ,IAAI,SAAS,KAAK,CAAC;QAC1C,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,MAAO,CAAC;QACrC,KAAK,EACH,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;YAC5D,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE;YACrB,CAAC,CAAC,IAAI;KACX,CAAC;AACJ,CAAC;AAED,yEAAyE;AACzE,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC;AAEvC,uEAAuE;AACvE,SAAS,SAAS,CAAC,IAAoB,EAAE,MAAmB;IAC1D,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC;IAChB,IAAI,CAAC,WAAW,IAAI,MAAM,CAAC,WAAW,CAAC;IACvC,IAAI,CAAC,eAAe,IAAI,MAAM,CAAC,eAAe,CAAC;IAC/C,IAAI,CAAC,gBAAgB,IAAI,MAAM,CAAC,kBAAkB,GAAG,MAAM,CAAC,kBAAkB,CAAC;IAC/E,IAAI,CAAC,MAAM,CAAC,aAAa;QAAE,IAAI,CAAC,oBAAoB,IAAI,CAAC,CAAC;IAC1D,IAAI,CAAC,YAAY,IAAI,MAAM,CAAC,YAAY,CAAC;AAC3C,CAAC;AAED,SAAS,GAAG,CAAC,IAAoB,EAAE,MAAmB,EAAE,SAA2B,EAAE,EAAQ;IAC3F;;;;;;;;;;;OAWG;IACH,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/C,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IAEzB,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACxB,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,GAAG,gBAAgB,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACpE,MAAM,KAAK,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACpC,MAAM,GAAG,GAAG,CAAC,MAAc,EAAE,IAAY,EAAU,EAAE,CAAC,CAAC,MAAM,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC;IAElF,IAAI,CAAC,QAAQ,IAAI,GAAG,CAAC,MAAM,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;IACvD,IAAI,CAAC,YAAY,IAAI,GAAG,CAAC,MAAM,CAAC,eAAe,EAAE,YAAY,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC;IACjF;;;;;OAKG;IACH,IAAI,CAAC,aAAa,IAAI,GAAG,CAAC,MAAM,CAAC,kBAAkB,EAAE,YAAY,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC;IACxF,IAAI,CAAC,aAAa,IAAI,GAAG,CAAC,MAAM,CAAC,kBAAkB,EAAE,YAAY,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC;IACxF,IAAI,CAAC,SAAS,IAAI,GAAG,CAAC,MAAM,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC;IAC1D,IAAI,CAAC,QAAQ;QACX,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC;IAC1E,OAAO,IAAI,CAAC;AACd,CAAC;AAQD;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,OAA4B;IACrE,MAAM,EAAE,SAAS,EAAE,EAAE,GAAG,IAAI,IAAI,EAAE,EAAE,GAAG,OAAO,CAAC;IAE/C,MAAM,KAAK,GAAG,KAAK,EAAE,CAAC;IACtB,MAAM,QAAQ,GAAG,KAAK,EAAE,CAAC;IACzB,MAAM,OAAO,GAAG,IAAI,GAAG,EAA0B,CAAC;IAClD,MAAM,OAAO,GAAG,IAAI,GAAG,EAA0B,CAAC;IAClD,MAAM,cAAc,GAAG,IAAI,GAAG,EAAU,CAAC;IACzC,MAAM,YAAY,GAAa,EAAE,CAAC;IAElC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,CAAC;QAC9B,IAAI,IAAI,KAAK,EAAE;YAAE,SAAS;QAE1B,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,YAAY,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACzB,SAAS;QACX,CAAC;QAED,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,CAAC,EAAE,CAAC;YACvC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACjC,SAAS,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC5B,4EAA4E;YAC5E,4EAA4E;YAC5E,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC;gBAAE,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YACnE,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAE,EAAE,MAAM,CAAC,CAAC;YAC9C,SAAS;QACX,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,IAAI,UAAU,CAAC;QAC5C,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;QAC3D,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAE,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC;QAEnD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QACnE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAE,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,MAAM,MAAM,GAAG,CACb,GAAgC,EAChC,GAAM,EACoD,EAAE,CAC5D,CAAC,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;SACf,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SACzE,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAEtD,CAAC,CAAC;IAEP,OAAO;QACL,KAAK;QACL,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC;QACjC,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC;QACjC,cAAc,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,IAAI,EAAE;QAC1C,QAAQ;QACR,YAAY;KACb,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,QAAQ,CAAC,SAAyB;IAChD,MAAM,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC;IAC/B,IAAI,QAAQ,IAAI,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IAC/E,OAAO;QACL,KAAK,EAAE,SAAS,CAAC,QAAQ,GAAG,QAAQ;QACpC,SAAS,EAAE,SAAS,CAAC,YAAY,GAAG,QAAQ;QAC5C,UAAU,EAAE,SAAS,CAAC,aAAa,GAAG,QAAQ;QAC9C,MAAM,EAAE,SAAS,CAAC,SAAS,GAAG,QAAQ;KACvC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,SAAyB;IACpD,MAAM,QAAQ,GAAG,SAAS,CAAC,eAAe,GAAG,SAAS,CAAC,WAAW,CAAC;IACnE,IAAI,SAAS,CAAC,eAAe,KAAK,CAAC,IAAI,SAAS,CAAC,gBAAgB,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrF,IAAI,QAAQ,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAChC,OAAO,SAAS,CAAC,eAAe,GAAG,QAAQ,CAAC;AAC9C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/core",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Trazum core: priced advisories for LLM prompts (caching, model tier, batching, schemas), plus deterministic trimming, token counting and pricing.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
package/src/advisories.ts CHANGED
@@ -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
  });
@@ -223,12 +271,49 @@ export function buildAdvisories(
223
271
  });
224
272
  }
225
273
 
226
- // Stable content placed AFTER the first placeholder: never cached today,
227
- // 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
+
228
312
  if (
229
313
  cache.firstPlaceholder &&
230
314
  cache.staticTokensAfter >= 200 &&
231
- cache.staticTokensAfter >= tokensAfter * 0.3
315
+ cache.staticTokensAfter >= tokensAfter * 0.3 &&
316
+ reachableAfterReorder
232
317
  ) {
233
318
  const movableShare = tokensAfter > 0 ? cache.staticTokensAfter / tokensAfter : 0;
234
319
  const saving = monthlyInputUsd * movableShare * Math.max(0, 1 - factor);
@@ -239,6 +324,13 @@ export function buildAdvisories(
239
324
  staticTokensAfter: cache.staticTokensAfter,
240
325
  sharePct: Math.round(movableShare * 100),
241
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',
242
334
  }),
243
335
  estimatedMonthlyUsd: saving > 0 ? saving : null,
244
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
 
@@ -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,19 @@
1
1
  export * from './types.js';
2
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';
3
17
  export { DETECTABLE_LANGUAGES, detectTextLanguage } from './language.js';
4
18
  export { countSentences, profilePrompt } from './profile.js';
5
19
  export { PHRASE_LANGUAGES } from './phrases.js';