@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.
- package/README.md +12 -5
- package/dist/advisories.d.ts.map +1 -1
- package/dist/advisories.js +94 -4
- package/dist/advisories.js.map +1 -1
- package/dist/against.d.ts +32 -0
- package/dist/against.d.ts.map +1 -0
- package/dist/against.js +34 -0
- package/dist/against.js.map +1 -0
- package/dist/config-schema.d.ts +42 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +61 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/conversation.d.ts +121 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +157 -0
- package/dist/conversation.js.map +1 -0
- package/dist/csv.d.ts +61 -0
- package/dist/csv.d.ts.map +1 -0
- package/dist/csv.js +149 -0
- package/dist/csv.js.map +1 -0
- package/dist/evaluate.d.ts +24 -0
- package/dist/evaluate.d.ts.map +1 -1
- package/dist/evaluate.js +5 -2
- package/dist/evaluate.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +18 -7
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +18 -7
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +28 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/input-shape.d.ts +104 -0
- package/dist/input-shape.d.ts.map +1 -0
- package/dist/input-shape.js +132 -0
- package/dist/input-shape.js.map +1 -0
- package/dist/levers.d.ts +151 -0
- package/dist/levers.d.ts.map +1 -0
- package/dist/levers.js +160 -0
- package/dist/levers.js.map +1 -0
- package/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/output-shape.d.ts +96 -0
- package/dist/output-shape.d.ts.map +1 -0
- package/dist/output-shape.js +145 -0
- package/dist/output-shape.js.map +1 -0
- package/dist/pricing-overlay.d.ts +1 -1
- package/dist/pricing-overlay.d.ts.map +1 -1
- package/dist/pricing-overlay.js +46 -0
- package/dist/pricing-overlay.js.map +1 -1
- package/dist/repeats.d.ts +75 -0
- package/dist/repeats.d.ts.map +1 -0
- package/dist/repeats.js +82 -0
- package/dist/repeats.js.map +1 -0
- package/dist/reprice.d.ts +143 -0
- package/dist/reprice.d.ts.map +1 -0
- package/dist/reprice.js +82 -0
- package/dist/reprice.js.map +1 -0
- package/dist/session-cost.d.ts +70 -0
- package/dist/session-cost.d.ts.map +1 -0
- package/dist/session-cost.js +90 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/session-ledger.d.ts +77 -0
- package/dist/session-ledger.d.ts.map +1 -0
- package/dist/session-ledger.js +99 -0
- package/dist/session-ledger.js.map +1 -0
- package/dist/tokenizer.d.ts +33 -26
- package/dist/tokenizer.d.ts.map +1 -1
- package/dist/tokenizer.js +95 -34
- package/dist/tokenizer.js.map +1 -1
- package/dist/ttl-fit.d.ts +103 -0
- package/dist/ttl-fit.d.ts.map +1 -0
- package/dist/ttl-fit.js +184 -0
- package/dist/ttl-fit.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +637 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +634 -0
- package/dist/usage.js.map +1 -0
- package/package.json +1 -1
- package/src/advisories.ts +96 -4
- package/src/against.ts +48 -0
- package/src/config-schema.ts +106 -0
- package/src/conversation.ts +305 -0
- package/src/csv.ts +184 -0
- package/src/evaluate.ts +33 -3
- package/src/i18n/en.ts +19 -6
- package/src/i18n/es.ts +19 -6
- package/src/i18n/types.ts +29 -1
- package/src/index.ts +65 -1
- package/src/input-shape.ts +259 -0
- package/src/levers.ts +331 -0
- package/src/node.ts +1 -1
- package/src/output-shape.ts +254 -0
- package/src/pricing-overlay.ts +52 -1
- package/src/repeats.ts +166 -0
- package/src/reprice.ts +227 -0
- package/src/session-cost.ts +170 -0
- package/src/session-ledger.ts +189 -0
- package/src/tokenizer.ts +93 -30
- package/src/ttl-fit.ts +251 -0
- package/src/types.ts +1 -0
- package/src/usage.ts +1267 -0
package/src/tokenizer.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is NOT a real tokenizer: it is a heuristic calibrated per character
|
|
5
5
|
* class. It is built to keep the typical error on ordinary text
|
|
6
|
-
* (English/Spanish, markdown, code) inside ±
|
|
6
|
+
* (English/Spanish, markdown, code) inside ±10%, which is plenty for comparing
|
|
7
7
|
* two versions of the same prompt — but do NOT bill anyone from it.
|
|
8
8
|
*
|
|
9
9
|
* **That band is a design target that has not been measured.** It is printed on
|
|
@@ -27,38 +27,67 @@ import { SAFE_FETCH_INIT, checkedEndpoint } from './net.js';
|
|
|
27
27
|
/**
|
|
28
28
|
* The error band this estimator is published under, as a percentage.
|
|
29
29
|
*
|
|
30
|
-
* **Measured, not chosen.** It was `15` for eight
|
|
31
|
-
* nobody had checked
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* **Measured, not chosen, and it has moved three times.** It was `15` for eight
|
|
31
|
+
* releases as a design target nobody had checked; the first measurement found two
|
|
32
|
+
* of eight samples outside it and it went to `25`; fixing the digit divisor and
|
|
33
|
+
* calibrating per language brought it back to `15`. This is the fourth value and
|
|
34
|
+
* the first one that is comfortably above what the corpus actually shows.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* samples across four languages and six text types.
|
|
36
|
+
* ```
|
|
37
|
+
* worst measured error 6.4% (code-heavy, which nothing is fitted to)
|
|
38
|
+
* published band 10%
|
|
39
|
+
* ```
|
|
41
40
|
*
|
|
42
|
-
* **
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
41
|
+
* **The margin is deliberate and it is not slack.** 6.4 rounded up is 7, and
|
|
42
|
+
* publishing 7 would be a tighter claim than twenty-one samples across six text
|
|
43
|
+
* types can support: the corpus has no Korean, no Arabic, no Cyrillic prose, no
|
|
44
|
+
* mixed-script document, and a seventh text type could easily land at eight. A
|
|
45
|
+
* band that becomes false the first time somebody measures something new is the
|
|
46
|
+
* exact fault this whole exercise was fixing. Overstating the uncertainty is the
|
|
47
|
+
* safe direction for a tool that reports money.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* What earned the drop from 15 was **splitting kana from han**. Every CJK
|
|
50
|
+
* character was charged one token, which put Japanese at +11.2% — the worst error
|
|
51
|
+
* anywhere in the corpus — while Chinese sat at −3.2% under the same rule. Kana
|
|
52
|
+
* measure 0.75 tokens per character and han 1.05, and that pair takes the two
|
|
53
|
+
* samples to −1.5% and +1.3%. See `KANA_TOKENS_PER_CHAR`.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* **Which samples the band rests on matters more than the number.** Eight of the
|
|
56
|
+
* twenty-one had a constant fitted to them — seven Latin divisors and the digit
|
|
57
|
+
* divisor — so their residuals are optimistic by construction. The two worst
|
|
58
|
+
* errors in the corpus, `code-heavy` at 6.4% and `punctuation-heavy` at 5.7%, are
|
|
59
|
+
* fitted to nothing at all, and they are what sets this figure.
|
|
60
|
+
*
|
|
61
|
+
* Exported so every report, README and tool description reads the same number. It
|
|
62
|
+
* was a literal in twenty-four files before this, with the only machine-readable
|
|
63
|
+
* copy in a test, and `token-band.test.js` now fails any file that states a
|
|
64
|
+
* different one.
|
|
58
65
|
*/
|
|
59
|
-
export const ESTIMATE_ERROR_BAND_PCT =
|
|
66
|
+
export const ESTIMATE_ERROR_BAND_PCT = 10;
|
|
60
67
|
|
|
61
68
|
const CJK = /[-ヿ㐀-䶿一-鿿가-]/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Kana, separated from the rest of CJK because they do not cost the same.
|
|
72
|
+
*
|
|
73
|
+
* **This was the largest error left in the corpus.** Every CJK character was
|
|
74
|
+
* charged one token, and measured against the counting endpoint that put Japanese
|
|
75
|
+
* at **+11.2%** — the worst figure anywhere in twenty-one samples — while Chinese
|
|
76
|
+
* came out at −3.2% under the identical rule. One constant cannot be right for
|
|
77
|
+
* both, and the reason is visible in the samples: the Japanese one is 58% kana and
|
|
78
|
+
* the Chinese one is 0%.
|
|
79
|
+
*
|
|
80
|
+
* Kana are a small syllabary that appears in every sentence, so the merge table
|
|
81
|
+
* covers runs of them and several characters share a token. Han are tens of
|
|
82
|
+
* thousands of rare characters; a merge table cannot cover them and they cost
|
|
83
|
+
* about one each, sometimes more.
|
|
84
|
+
*
|
|
85
|
+
* The signal needs no detector. A character is kana or it is not, and the two
|
|
86
|
+
* samples separate perfectly — 58.3% against 0.00% — so this is a property of the
|
|
87
|
+
* character rather than a guess about the document. That is the difference between
|
|
88
|
+
* this and `language.ts`, which has to decide and is allowed to refuse.
|
|
89
|
+
*/
|
|
90
|
+
const KANA = /[-ヿ]/;
|
|
62
91
|
const LETTER = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
|
|
63
92
|
const DIGIT = /[0-9]/;
|
|
64
93
|
|
|
@@ -95,6 +124,27 @@ const DIVISOR_BY_LANGUAGE: Readonly<Record<string, number>> = {
|
|
|
95
124
|
nl: 2.65,
|
|
96
125
|
};
|
|
97
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Tokens per character for the two halves of CJK, measured.
|
|
129
|
+
*
|
|
130
|
+
* `0.75` and `1.05` come from a search over the two CJK samples in
|
|
131
|
+
* `test/fixtures/token-ground-truth.json`, and they take that pair from
|
|
132
|
+
* +11.2% / −3.2% to −1.5% / +1.3%.
|
|
133
|
+
*
|
|
134
|
+
* **Two samples fitted two constants, so those residuals are in-sample and
|
|
135
|
+
* optimistic by construction** — the same caveat the Latin divisors carry, stated
|
|
136
|
+
* for the same reason. What makes them worth having anyway is the size of the
|
|
137
|
+
* error they replace and the fact that they move in opposite directions: a single
|
|
138
|
+
* constant could not have been within four points of both, whatever it was set to.
|
|
139
|
+
* The honest test is a third CJK sample, and the corpus grows one at a time.
|
|
140
|
+
*
|
|
141
|
+
* Hangul keeps the old cost of 1, because nothing here measures Korean. Guessing
|
|
142
|
+
* it from Japanese would be inventing a figure — the two scripts have nothing in
|
|
143
|
+
* common that would make one predict the other.
|
|
144
|
+
*/
|
|
145
|
+
const KANA_TOKENS_PER_CHAR = 0.75;
|
|
146
|
+
const HAN_TOKENS_PER_CHAR = 1.05;
|
|
147
|
+
|
|
98
148
|
/**
|
|
99
149
|
* What a prompt gets when the language could not be told.
|
|
100
150
|
*
|
|
@@ -133,6 +183,11 @@ export function estimateTokens(text: string): number {
|
|
|
133
183
|
const divisor = (language === null ? undefined : DIVISOR_BY_LANGUAGE[language]) ?? DEFAULT_DIVISOR;
|
|
134
184
|
|
|
135
185
|
let total = 0;
|
|
186
|
+
/**
|
|
187
|
+
* CJK is summed separately because it is the one class counted in fractions of
|
|
188
|
+
* a token. Everything else is whole tokens by the character class it belongs to.
|
|
189
|
+
*/
|
|
190
|
+
let cjkTokens = 0;
|
|
136
191
|
let i = 0;
|
|
137
192
|
const chars = Array.from(text);
|
|
138
193
|
|
|
@@ -155,12 +210,19 @@ export function estimateTokens(text: string): number {
|
|
|
155
210
|
}
|
|
156
211
|
|
|
157
212
|
if (CJK.test(ch)) {
|
|
158
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Accumulated as a fraction and rounded once, at the end.
|
|
215
|
+
*
|
|
216
|
+
* Rounding up per run was the first attempt and it was wrong by five
|
|
217
|
+
* points. Ordinary Japanese alternates kana and han inside every sentence,
|
|
218
|
+
* so the runs are short and there are many of them — and a `Math.ceil` per
|
|
219
|
+
* run charges most of a token for each boundary. That is an artefact of
|
|
220
|
+
* where the loop happens to break, not of what the text costs.
|
|
221
|
+
*/
|
|
159
222
|
while (i < chars.length && CJK.test(chars[i]!)) {
|
|
160
|
-
|
|
223
|
+
cjkTokens += KANA.test(chars[i]!) ? KANA_TOKENS_PER_CHAR : HAN_TOKENS_PER_CHAR;
|
|
161
224
|
i++;
|
|
162
225
|
}
|
|
163
|
-
total += n;
|
|
164
226
|
continue;
|
|
165
227
|
}
|
|
166
228
|
|
|
@@ -229,7 +291,8 @@ export function estimateTokens(text: string): number {
|
|
|
229
291
|
}
|
|
230
292
|
}
|
|
231
293
|
|
|
232
|
-
|
|
294
|
+
// Rounded once, over the whole document, rather than per run — see the CJK branch.
|
|
295
|
+
return total + Math.ceil(cjkTokens);
|
|
233
296
|
}
|
|
234
297
|
|
|
235
298
|
/** Asynchronous token counter, for remote sources. */
|
package/src/ttl-fit.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { effectivePricing, multipliersFor } from './pricing.js';
|
|
2
|
+
import { UNLABELLED } from './usage.js';
|
|
3
|
+
import type { PricingCatalogue } from './pricing.js';
|
|
4
|
+
import type { UsageRecord } from './usage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Does the cache TTL fit how fast the turns actually arrive?
|
|
8
|
+
*
|
|
9
|
+
* ## The mechanism nothing else can see
|
|
10
|
+
*
|
|
11
|
+
* A cache entry lives 5 minutes, or an hour at twice the write price. Whether
|
|
12
|
+
* either is the right choice depends on one number the bill never shows: **how
|
|
13
|
+
* long the workload waits between turns.** A support agent whose users answer in
|
|
14
|
+
* nine minutes writes a 5-minute entry on every turn and reads it back on none of
|
|
15
|
+
* them — every write expires unread, which from the bill is indistinguishable
|
|
16
|
+
* from any other losing cache. `cacheEconomics` can say *that* money was lost;
|
|
17
|
+
* only the clock can say *why*, and the why decides the fix: the 1-hour TTL, or
|
|
18
|
+
* caching switched off.
|
|
19
|
+
*
|
|
20
|
+
* The opposite mistake is quieter and this is the only place it appears at all:
|
|
21
|
+
* turns arriving seconds apart, written at the 1-hour rate. Those writes work —
|
|
22
|
+
* the verdict above reads `paid-off` — and every one of them pays 2x input for
|
|
23
|
+
* endurance the workload never uses. **Switching them to the 5-minute TTL is the
|
|
24
|
+
* one exact saving in this module**: the same tokens at 1.25x instead of 2x,
|
|
25
|
+
* which is the same-tokens-different-rate arithmetic `cacheEconomics` already
|
|
26
|
+
* draws the counterfactual line at.
|
|
27
|
+
*
|
|
28
|
+
* ## What it measures, and how it stays honest
|
|
29
|
+
*
|
|
30
|
+
* The gap between consecutive turns **of the same conversation**, from the
|
|
31
|
+
* recorded clock — sessions are what a cache entry actually serves, and gaps
|
|
32
|
+
* between unrelated calls of a label say nothing about whether *this*
|
|
33
|
+
* conversation's next turn found the entry alive. Timestamps are sorted within
|
|
34
|
+
* each session before differencing, so the measurement is independent of the
|
|
35
|
+
* order of the log — the property the conversation tracker had to learn the
|
|
36
|
+
* hard way.
|
|
37
|
+
*
|
|
38
|
+
* The reported number is the **median** gap, named as such: a median survives
|
|
39
|
+
* the overnight gap between a user's Tuesday and Wednesday in a way a mean does
|
|
40
|
+
* not, and a verdict hung on a mean would flip on one lunch break.
|
|
41
|
+
*
|
|
42
|
+
* When the log did not record which TTL the writes used, the gap can sit where
|
|
43
|
+
* the verdict depends on the answer — over 5 minutes and under an hour survives
|
|
44
|
+
* one TTL and not the other. That is reported as `unsettled`, the same refusal
|
|
45
|
+
* `cacheEconomics` makes for the same missing field, and never resolved in the
|
|
46
|
+
* flattering direction.
|
|
47
|
+
*
|
|
48
|
+
* The session key is used to group turns and never leaves this module, like
|
|
49
|
+
* everywhere else the field is touched.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** Cache-entry lifetimes, in milliseconds. Anthropic's two published TTLs. */
|
|
53
|
+
export const TTL_5M_MS = 5 * 60 * 1000;
|
|
54
|
+
export const TTL_1H_MS = 60 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
export type TtlFitVerdict =
|
|
57
|
+
/** The median gap outlives the entry: writes expire before the next turn. */
|
|
58
|
+
| 'expires-before-reuse'
|
|
59
|
+
/** 1-hour writes on gaps inside the 5-minute window: paying 2x for nothing. */
|
|
60
|
+
| 'overlong-ttl'
|
|
61
|
+
/** The TTL the log did not record decides the verdict, so nothing does. */
|
|
62
|
+
| 'unsettled'
|
|
63
|
+
/** The entry outlives the gap at the TTL the writes actually used. */
|
|
64
|
+
| 'fits';
|
|
65
|
+
|
|
66
|
+
export interface CacheTtlFit {
|
|
67
|
+
label: string;
|
|
68
|
+
model: string;
|
|
69
|
+
modelName: string;
|
|
70
|
+
/** Conversations with at least two timestamped turns. Never which ones. */
|
|
71
|
+
sessions: number;
|
|
72
|
+
/** Gaps measured across them. */
|
|
73
|
+
gaps: number;
|
|
74
|
+
medianGapMs: number;
|
|
75
|
+
/** Write tokens the log said were 5-minute entries. */
|
|
76
|
+
write5mTokens: number;
|
|
77
|
+
/** Write tokens the log said were 1-hour entries. */
|
|
78
|
+
write1hTokens: number;
|
|
79
|
+
/** Write tokens whose TTL the log did not record. */
|
|
80
|
+
assumedTtlTokens: number;
|
|
81
|
+
verdict: TtlFitVerdict;
|
|
82
|
+
/**
|
|
83
|
+
* What the 1-hour writes would save at the 5-minute rate, when the gaps show
|
|
84
|
+
* the hour is never needed. Exact — the same tokens at 1.25x instead of 2x,
|
|
85
|
+
* at the model's own input rate — and zero for every other verdict.
|
|
86
|
+
*/
|
|
87
|
+
overpayUsd: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TtlFitOptions {
|
|
91
|
+
catalogue: PricingCatalogue;
|
|
92
|
+
/** Date the prices are read at, so a promotional rate resolves the same way. */
|
|
93
|
+
on?: Date;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface TtlFitTracker {
|
|
97
|
+
/** Feed one parsed record. */
|
|
98
|
+
add(record: UsageRecord): void;
|
|
99
|
+
/** The finished measurement. */
|
|
100
|
+
finish(): CacheTtlFit[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface Slice {
|
|
104
|
+
write5mTokens: number;
|
|
105
|
+
write1hTokens: number;
|
|
106
|
+
assumedTtlTokens: number;
|
|
107
|
+
/** Timestamps per session, sorted only at the end. */
|
|
108
|
+
sessions: Map<string, number[]>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const median = (sorted: number[]): number => {
|
|
112
|
+
const mid = Math.floor(sorted.length / 2);
|
|
113
|
+
return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An accumulator, like the conversation tracker and for the same reason: a
|
|
118
|
+
* usage log is measured in megabytes and `profileUsage` makes one pass. What
|
|
119
|
+
* this holds is one number per timestamped call that belongs to a session,
|
|
120
|
+
* which is the minimum the gaps can be computed from at all.
|
|
121
|
+
*/
|
|
122
|
+
export function createTtlFitTracker(options: TtlFitOptions): TtlFitTracker {
|
|
123
|
+
const { catalogue, on = new Date() } = options;
|
|
124
|
+
const slices = new Map<string, Slice>();
|
|
125
|
+
|
|
126
|
+
const add = (record: UsageRecord): void => {
|
|
127
|
+
// An unpriced model has no rates to judge a TTL against, and contributes no
|
|
128
|
+
// dollars anywhere else either.
|
|
129
|
+
if (!catalogue.byId.has(record.model)) return;
|
|
130
|
+
|
|
131
|
+
const writes =
|
|
132
|
+
record.cacheWrite5mTokens + record.cacheWrite1hTokens;
|
|
133
|
+
const hasClockedTurn = record.session !== null && record.ts !== null;
|
|
134
|
+
// A slice exists once it writes to the cache or can contribute a gap;
|
|
135
|
+
// everything else has nothing to say here.
|
|
136
|
+
if (writes === 0 && !hasClockedTurn) return;
|
|
137
|
+
|
|
138
|
+
const key = `${record.label ?? UNLABELLED}\n${record.model}`;
|
|
139
|
+
let slice = slices.get(key);
|
|
140
|
+
if (!slice) {
|
|
141
|
+
slice = { write5mTokens: 0, write1hTokens: 0, assumedTtlTokens: 0, sessions: new Map() };
|
|
142
|
+
slices.set(key, slice);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (record.writeTtlKnown) {
|
|
146
|
+
slice.write5mTokens += record.cacheWrite5mTokens;
|
|
147
|
+
slice.write1hTokens += record.cacheWrite1hTokens;
|
|
148
|
+
} else {
|
|
149
|
+
// The flat count sits in the 5m bucket by pricing convention; here it is
|
|
150
|
+
// kept apart, because the whole question is which TTL it really was.
|
|
151
|
+
slice.assumedTtlTokens += record.cacheWrite5mTokens;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (hasClockedTurn) {
|
|
155
|
+
const turns = slice.sessions.get(record.session!);
|
|
156
|
+
if (turns) turns.push(record.ts!);
|
|
157
|
+
else slice.sessions.set(record.session!, [record.ts!]);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const finish = (): CacheTtlFit[] => {
|
|
162
|
+
const out: CacheTtlFit[] = [];
|
|
163
|
+
|
|
164
|
+
for (const [key, slice] of slices) {
|
|
165
|
+
const writes = slice.write5mTokens + slice.write1hTokens + slice.assumedTtlTokens;
|
|
166
|
+
// No writes means no TTL to judge. Gaps alone are the conversation
|
|
167
|
+
// tracker's business, not this module's.
|
|
168
|
+
if (writes === 0) continue;
|
|
169
|
+
|
|
170
|
+
const gaps: number[] = [];
|
|
171
|
+
let sessions = 0;
|
|
172
|
+
for (const turns of slice.sessions.values()) {
|
|
173
|
+
if (turns.length < 2) continue;
|
|
174
|
+
sessions += 1;
|
|
175
|
+
turns.sort((a, b) => a - b);
|
|
176
|
+
for (let i = 1; i < turns.length; i += 1) gaps.push(turns[i]! - turns[i - 1]!);
|
|
177
|
+
}
|
|
178
|
+
// Writes with no measurable gap: the caller reports "could not be
|
|
179
|
+
// measured" from the absence of a row, the same way missing sessions
|
|
180
|
+
// read everywhere else.
|
|
181
|
+
if (gaps.length === 0) continue;
|
|
182
|
+
|
|
183
|
+
const split = key.indexOf('\n');
|
|
184
|
+
const modelId = key.slice(split + 1);
|
|
185
|
+
const model = catalogue.byId.get(modelId)!;
|
|
186
|
+
|
|
187
|
+
gaps.sort((a, b) => a - b);
|
|
188
|
+
const gap = median(gaps);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* First match wins, ordered so that a certain failure outranks an
|
|
192
|
+
* uncertain one and money comes last: writes that expire are broken at
|
|
193
|
+
* any price, and only writes that demonstrably work can be overpaying.
|
|
194
|
+
*/
|
|
195
|
+
let verdict: TtlFitVerdict;
|
|
196
|
+
if (slice.write5mTokens > 0 && gap > TTL_5M_MS) verdict = 'expires-before-reuse';
|
|
197
|
+
else if (slice.write1hTokens > 0 && gap > TTL_1H_MS) verdict = 'expires-before-reuse';
|
|
198
|
+
else if (slice.assumedTtlTokens > 0 && gap > TTL_1H_MS) verdict = 'expires-before-reuse';
|
|
199
|
+
else if (slice.write1hTokens > 0 && gap <= TTL_5M_MS) verdict = 'overlong-ttl';
|
|
200
|
+
else if (slice.assumedTtlTokens > 0 && gap > TTL_5M_MS) verdict = 'unsettled';
|
|
201
|
+
else verdict = 'fits';
|
|
202
|
+
|
|
203
|
+
const { inputPerMTok } = effectivePricing(model, on);
|
|
204
|
+
const rates = multipliersFor(model);
|
|
205
|
+
const overpayUsd =
|
|
206
|
+
verdict === 'overlong-ttl'
|
|
207
|
+
? (slice.write1hTokens / 1_000_000) *
|
|
208
|
+
inputPerMTok *
|
|
209
|
+
(rates.cacheWrite1h - rates.cacheWrite5m)
|
|
210
|
+
: 0;
|
|
211
|
+
|
|
212
|
+
out.push({
|
|
213
|
+
label: key.slice(0, split),
|
|
214
|
+
model: modelId,
|
|
215
|
+
modelName: model.displayName,
|
|
216
|
+
sessions,
|
|
217
|
+
gaps: gaps.length,
|
|
218
|
+
medianGapMs: gap,
|
|
219
|
+
write5mTokens: slice.write5mTokens,
|
|
220
|
+
write1hTokens: slice.write1hTokens,
|
|
221
|
+
assumedTtlTokens: slice.assumedTtlTokens,
|
|
222
|
+
verdict,
|
|
223
|
+
overpayUsd,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Broken first, then overpaying by money, then the rest — the order
|
|
228
|
+
// somebody would act in.
|
|
229
|
+
const rank: Record<TtlFitVerdict, number> = {
|
|
230
|
+
'expires-before-reuse': 0,
|
|
231
|
+
'overlong-ttl': 1,
|
|
232
|
+
unsettled: 2,
|
|
233
|
+
fits: 3,
|
|
234
|
+
};
|
|
235
|
+
return out.sort(
|
|
236
|
+
(a, b) => rank[a.verdict] - rank[b.verdict] || b.overpayUsd - a.overpayUsd,
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return { add, finish };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The same measurement over a list, for a caller holding one already. */
|
|
244
|
+
export function cacheTtlFit(
|
|
245
|
+
records: readonly UsageRecord[],
|
|
246
|
+
options: TtlFitOptions,
|
|
247
|
+
): CacheTtlFit[] {
|
|
248
|
+
const tracker = createTtlFitTracker(options);
|
|
249
|
+
for (const record of records) tracker.add(record);
|
|
250
|
+
return tracker.finish();
|
|
251
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type AdvisorySeverity = 'info' | 'opportunity' | 'warning';
|
|
|
44
44
|
/** Every advisory the core can emit. */
|
|
45
45
|
export type AdvisoryId =
|
|
46
46
|
| 'context-overflow'
|
|
47
|
+
| 'context-near-limit'
|
|
47
48
|
| 'prompt-caching'
|
|
48
49
|
| 'prompt-caching-not-worth-it'
|
|
49
50
|
| 'below-cache-minimum'
|