@maxiaochao/pi-toolkit 0.1.4 → 0.1.5
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/CHANGELOG.md
CHANGED
|
@@ -32,6 +32,7 @@ export function parseEntries(entries) {
|
|
|
32
32
|
const toolCounts = {};
|
|
33
33
|
let compactions = 0;
|
|
34
34
|
let prevKey = null;
|
|
35
|
+
const longCacheUntilByKey = new Map();
|
|
35
36
|
let gapTools = []; // tool results accumulated since the previous request
|
|
36
37
|
|
|
37
38
|
const addEvent = (num, kind, label, ts) =>
|
|
@@ -88,12 +89,27 @@ export function parseEntries(entries) {
|
|
|
88
89
|
const num = requests.length + 1;
|
|
89
90
|
const provider = m.provider || "?";
|
|
90
91
|
const model = m.model || "?";
|
|
92
|
+
const api = m.api || "";
|
|
91
93
|
const key = `${provider}/${model}`;
|
|
92
94
|
if (prevKey !== null && key !== prevKey && !events.some((ev) => ev.requestNumber === num && ev.kind === "model")) {
|
|
93
95
|
addEvent(num, "model", `Model changed to ${provider}/${model}`, ts);
|
|
94
96
|
}
|
|
95
97
|
prevKey = key;
|
|
96
98
|
|
|
99
|
+
const tsMs = Date.parse(String(ts || ""));
|
|
100
|
+
let longCacheUntil = longCacheUntilByKey.get(key) || 0;
|
|
101
|
+
if (u.cacheRead === 0 && u.cacheWrite > 0 && !u.cacheWrite1h) {
|
|
102
|
+
longCacheUntil = 0;
|
|
103
|
+
longCacheUntilByKey.delete(key);
|
|
104
|
+
}
|
|
105
|
+
if (u.cacheWrite1h > 0 && Number.isFinite(tsMs)) {
|
|
106
|
+
longCacheUntil = Math.max(longCacheUntil, tsMs + LONG_TTL_MINUTES * 60000);
|
|
107
|
+
longCacheUntilByKey.set(key, longCacheUntil);
|
|
108
|
+
}
|
|
109
|
+
const cacheTtlMinutes = Number.isFinite(tsMs) && tsMs <= longCacheUntil
|
|
110
|
+
? LONG_TTL_MINUTES
|
|
111
|
+
: DEFAULT_TTL_MINUTES;
|
|
112
|
+
|
|
97
113
|
const cost = u.cost && typeof u.cost.total === "number" ? u.cost.total : null;
|
|
98
114
|
requests.push({
|
|
99
115
|
number: num,
|
|
@@ -101,11 +117,13 @@ export function parseEntries(entries) {
|
|
|
101
117
|
timestamp: hhmmss(ts),
|
|
102
118
|
provider,
|
|
103
119
|
model,
|
|
120
|
+
api,
|
|
104
121
|
responseProvider: null,
|
|
105
122
|
fresh,
|
|
106
123
|
cached,
|
|
107
124
|
cacheWrite,
|
|
108
125
|
cacheWrite1h: u.cacheWrite1h || 0,
|
|
126
|
+
cacheTtlMinutes,
|
|
109
127
|
output,
|
|
110
128
|
reasoning: u.reasoning || 0,
|
|
111
129
|
stopReason: m.stopReason || "",
|
|
@@ -116,11 +134,6 @@ export function parseEntries(entries) {
|
|
|
116
134
|
gapTools = [];
|
|
117
135
|
}
|
|
118
136
|
|
|
119
|
-
// tau flushes trailing events onto the last request so chart tooltips can find them
|
|
120
|
-
if (requests.length > 0) {
|
|
121
|
-
for (const ev of events) ev.requestNumber = Math.min(ev.requestNumber, requests.length);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
137
|
const toolCalls = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]);
|
|
125
138
|
return { requests, events, toolCalls, compactions };
|
|
126
139
|
}
|
|
@@ -211,18 +224,20 @@ function lineChart(title, series, opts = {}) {
|
|
|
211
224
|
`<line class="hover-line" x1="${left}" y1="${top}" x2="${left}" y2="${top + plotHeight}" visibility="hidden"/>`,
|
|
212
225
|
);
|
|
213
226
|
const [evDark, evLight] = seriesColor("event");
|
|
214
|
-
events
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
`<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
events
|
|
228
|
+
.filter((event) => event.requestNumber >= 1 && event.requestNumber <= count)
|
|
229
|
+
.forEach((event, eventIndex) => {
|
|
230
|
+
const [x] = point(Math.max(0, Math.min(count - 1, event.requestNumber - 1)), 0);
|
|
231
|
+
const markerY = top + 7 + (eventIndex % 3) * 9;
|
|
232
|
+
const description = `${event.label} before request ${event.requestNumber} at ${event.timestamp}`;
|
|
233
|
+
parts.push(
|
|
234
|
+
`<g class="usage-event usage-event-${esc(event.kind, true)}" data-request="${event.requestNumber}" ` +
|
|
235
|
+
`data-event-info="${esc(description, true)}" role="img" aria-label="${esc(description, true)}">` +
|
|
236
|
+
`<title>${esc(description)}</title>` +
|
|
237
|
+
`<line class="event-line" data-dark="${evDark}" data-light="${evLight}" x1="${x.toFixed(1)}" y1="${top}" x2="${x.toFixed(1)}" y2="${top + plotHeight}" stroke="${evDark}"/>` +
|
|
238
|
+
`<circle class="event-marker" data-dark="${evDark}" data-light="${evLight}" cx="${x.toFixed(1)}" cy="${markerY.toFixed(1)}" r="4" fill="${evDark}"/></g>`,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
226
241
|
series.forEach(([name, values], seriesIndex) => {
|
|
227
242
|
const [dark, light] = seriesColor(name);
|
|
228
243
|
const points = values.map((value, i) => point(i, value).map((c) => c.toFixed(1)).join(",")).join(" ");
|
|
@@ -262,7 +277,20 @@ const figure = (chart) =>
|
|
|
262
277
|
// ---------------------------------------------------------------------------
|
|
263
278
|
|
|
264
279
|
const MISS_THRESHOLD = 0.5; // hit rate below this gets an explanation row
|
|
265
|
-
const
|
|
280
|
+
const PREFIX_REUSE_THRESHOLD = 0.5;
|
|
281
|
+
const DEFAULT_TTL_MINUTES = 5;
|
|
282
|
+
const LONG_TTL_MINUTES = 60;
|
|
283
|
+
|
|
284
|
+
function reusablePrefix(request) {
|
|
285
|
+
const prompt = request.fresh + request.cached + request.cacheWrite;
|
|
286
|
+
if (request.api !== "anthropic-messages") return prompt;
|
|
287
|
+
const explicitlyCached = request.cached + request.cacheWrite;
|
|
288
|
+
return explicitlyCached > 0 ? explicitlyCached : prompt;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function ttlMinutesAfter(request) {
|
|
292
|
+
return request.cacheTtlMinutes ?? (request.cacheWrite1h > 0 ? LONG_TTL_MINUTES : DEFAULT_TTL_MINUTES);
|
|
293
|
+
}
|
|
266
294
|
|
|
267
295
|
export function analyzeMisses(requests, events) {
|
|
268
296
|
const evByReq = new Map();
|
|
@@ -284,9 +312,11 @@ export function analyzeMisses(requests, events) {
|
|
|
284
312
|
|
|
285
313
|
const causes = [];
|
|
286
314
|
const prev = i > 0 ? requests[i - 1] : null;
|
|
315
|
+
const expectedPrefix = prev ? reusablePrefix(prev) : 0;
|
|
316
|
+
const prefixReuse = expectedPrefix > 0 ? Math.min(1, r.cached / expectedPrefix) : 0;
|
|
287
317
|
|
|
288
318
|
if (i === 0) {
|
|
289
|
-
causes.push(["first request", "cold session: nothing cached yet
|
|
319
|
+
causes.push(["first request", "cold session: nothing cached yet; the prefix can only be reused by a later request"]);
|
|
290
320
|
}
|
|
291
321
|
if (prev && (prev.provider !== r.provider || prev.model !== r.model)) {
|
|
292
322
|
causes.push(["model switch", `${prev.provider}/${prev.model} -> ${r.provider}/${r.model}: prefix invalidated by provider/model change`]);
|
|
@@ -302,19 +332,23 @@ export function analyzeMisses(requests, events) {
|
|
|
302
332
|
}
|
|
303
333
|
if (prev && r.isoTs && prev.isoTs) {
|
|
304
334
|
const gapMin = (Date.parse(r.isoTs) - Date.parse(prev.isoTs)) / 60000;
|
|
305
|
-
|
|
306
|
-
|
|
335
|
+
const ttlMinutes = ttlMinutesAfter(prev);
|
|
336
|
+
if (gapMin > ttlMinutes && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.some(([c]) => c === "compaction" || c === "model switch")) {
|
|
337
|
+
causes.push(["possible TTL expiry", `idle gap ${gapMin.toFixed(0)}min > ${ttlMinutes}min heuristic; actual retention depends on the provider`]);
|
|
307
338
|
}
|
|
308
339
|
}
|
|
309
|
-
if (
|
|
310
|
-
|
|
340
|
+
if (prev && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.length) {
|
|
341
|
+
const writeDetail = r.cacheWrite > 0 ? `; provider also reported ${r.cacheWrite.toLocaleString("en-US")} tokens written for the replacement prefix` : "";
|
|
342
|
+
causes.push(["cache rebuild", `only ${(prefixReuse * 100).toFixed(1)}% of the prior reusable prefix was read; the cache was likely lost or invalidated${writeDetail}`]);
|
|
343
|
+
}
|
|
344
|
+
if (r.cacheWrite > 0 && !causes.length) {
|
|
345
|
+
causes.push(["cache write", `provider reported ${r.cacheWrite.toLocaleString("en-US")} tokens written; a low read-hit rate is expected while a prefix is created or extended`]);
|
|
311
346
|
}
|
|
312
347
|
if (prev && !causes.length) {
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
348
|
+
const toolChars = (r.gapTools || []).reduce((sum, tool) => sum + tool.chars, 0);
|
|
349
|
+
const toolTokens = Math.round(toolChars / 4);
|
|
350
|
+
const toolDetail = toolTokens > 0 ? `, including ~${toolTokens.toLocaleString("en-US")} estimated tool-output tokens` : "";
|
|
351
|
+
causes.push(["new input", `reused ${(prefixReuse * 100).toFixed(1)}% of the prior prefix; ${r.fresh.toLocaleString("en-US")} fresh tokens were newly appended${toolDetail}`]);
|
|
318
352
|
}
|
|
319
353
|
if (!causes.length) {
|
|
320
354
|
causes.push(["unclassified", "no rule matched; usually new content appended beyond the cached prefix (large tool results / long user input)"]);
|
|
@@ -330,7 +364,8 @@ export function analyzeMisses(requests, events) {
|
|
|
330
364
|
}
|
|
331
365
|
|
|
332
366
|
const summaryArr = [...summary.entries()].sort((a, b) => b[1].fresh - a[1].fresh);
|
|
333
|
-
return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD,
|
|
367
|
+
return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD,
|
|
368
|
+
ttlMinutes: DEFAULT_TTL_MINUTES, longTtlMinutes: LONG_TTL_MINUTES };
|
|
334
369
|
}
|
|
335
370
|
|
|
336
371
|
// Streak detection: many consecutive requests stuck in a mid hit-rate band.
|
|
@@ -344,20 +379,31 @@ const STREAK_HIGH = 0.975;
|
|
|
344
379
|
const STREAK_MIN_LEN = 3;
|
|
345
380
|
const STREAK_MIN_FRESH = 800; // tokens per request to count as a streak member
|
|
346
381
|
const STREAK_MIN_PROMPT = 4000; // ignore tiny prefixes where ratios are meaningless
|
|
347
|
-
//
|
|
348
|
-
//
|
|
382
|
+
// Requests that report cacheWrite are cache-creation/rebuild events, not
|
|
383
|
+
// append-heavy read-hit streak members, so they terminate the current streak.
|
|
349
384
|
|
|
350
|
-
export function analyzeStreaks(requests) {
|
|
385
|
+
export function analyzeStreaks(requests, events = []) {
|
|
351
386
|
const streaks = [];
|
|
352
387
|
let cur = null;
|
|
353
388
|
let gaps = 0; // consecutive non-member requests inside the current run
|
|
389
|
+
let prevKey = null;
|
|
390
|
+
const resetBefore = new Set(events
|
|
391
|
+
.filter((event) => event.kind === "compaction" || event.kind === "model")
|
|
392
|
+
.map((event) => event.requestNumber));
|
|
354
393
|
const close = () => {
|
|
355
394
|
if (cur && cur.hits.length >= STREAK_MIN_LEN) streaks.push(cur);
|
|
356
395
|
cur = null;
|
|
357
396
|
gaps = 0;
|
|
358
397
|
};
|
|
359
398
|
for (const r of requests) {
|
|
360
|
-
const
|
|
399
|
+
const key = `${r.provider}/${r.model}`;
|
|
400
|
+
if ((prevKey !== null && key !== prevKey) || resetBefore.has(r.number)) close();
|
|
401
|
+
prevKey = key;
|
|
402
|
+
if (r.cacheWrite > 0) {
|
|
403
|
+
close();
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const prompt = r.fresh + r.cached;
|
|
361
407
|
let hit = 0;
|
|
362
408
|
let member = false;
|
|
363
409
|
if (prompt >= STREAK_MIN_PROMPT) {
|
|
@@ -407,7 +453,7 @@ function renderStreakPanel(streaks) {
|
|
|
407
453
|
.join("");
|
|
408
454
|
return `<div class="usage-panel"><h2>Hit-rate streak analysis</h2>` +
|
|
409
455
|
`<p class="usage-note">Rule-based, no LLM. A streak is ${STREAK_MIN_LEN}+ requests (single healthy outlier tolerated) with hit rate ` +
|
|
410
|
-
`${(STREAK_LOW * 100).toFixed(0)}–${(STREAK_HIGH * 100).toFixed(1)}% (
|
|
456
|
+
`${(STREAK_LOW * 100).toFixed(0)}–${(STREAK_HIGH * 100).toFixed(1)}% (requests reporting cacheWrite break the streak; fresh ≥ ${STREAK_MIN_FRESH} tok, prefix ≥ ${STREAK_MIN_PROMPT}): ` +
|
|
411
457
|
`usually each turn appends large fresh content (big tool outputs / long replies), not a cache failure. ` +
|
|
412
458
|
`Below ${(MISS_THRESHOLD * 100).toFixed(0)}% see the miss panel above. Tool tokens estimated at 4 chars/token.</p>` +
|
|
413
459
|
`<div class="usage-table-wrap" style="max-height:260px"><table>` +
|
|
@@ -418,7 +464,7 @@ function renderStreakPanel(streaks) {
|
|
|
418
464
|
function renderMissPanel(miss) {
|
|
419
465
|
if (miss.noCacheReporting) {
|
|
420
466
|
return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
|
|
421
|
-
`<p class="empty">
|
|
467
|
+
`<p class="empty">No cache reads or writes were reported. The provider may omit cache token fields, caching may be disabled, or this session may not have reused a prefix; hit rate cannot be determined.</p></div>`;
|
|
422
468
|
}
|
|
423
469
|
const summaryHtml = miss.summary
|
|
424
470
|
.map(([cause, s]) => `<div class="usage-tool"><span>${esc(cause)} (${s.count})</span><strong>${fmtInt(s.fresh)} fresh</strong></div>`)
|
|
@@ -431,7 +477,7 @@ function renderMissPanel(miss) {
|
|
|
431
477
|
.join("");
|
|
432
478
|
return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
|
|
433
479
|
`<p class="usage-note">Rule-based attribution, no LLM. Rows are requests below ${(miss.threshold * 100).toFixed(0)}% hit rate. ` +
|
|
434
|
-
`TTL
|
|
480
|
+
`TTL attribution uses a ${miss.ttlMinutes}min idle-gap heuristic, or ${miss.longTtlMinutes}min after a reported 1h cache write; actual retention is provider-specific.</p>` +
|
|
435
481
|
`<div class="usage-table-wrap" style="max-height:340px"><table>` +
|
|
436
482
|
`<thead><tr><th>#</th><th>Time</th><th>Hit rate</th><th>Fresh</th><th>Cause</th></tr></thead>` +
|
|
437
483
|
`<tbody>${rows || `<tr><td colspan="5" class="empty">None</td></tr>`}</tbody></table></div>` +
|
|
@@ -474,7 +520,7 @@ export function renderDashboard(data) {
|
|
|
474
520
|
["Model requests", fmtInt(requests.length)],
|
|
475
521
|
["Cache hit rate", cacheHitRate !== null ? fmtPct1(cacheHitRate) : "N/A"],
|
|
476
522
|
["Cached input", fmtInt(totalCached)],
|
|
477
|
-
["
|
|
523
|
+
["Reported cache writes", fmtInt(totalWrite)],
|
|
478
524
|
["Fresh input", fmtInt(totalFresh)],
|
|
479
525
|
["Total prompt input", fmtInt(totalPrompt)],
|
|
480
526
|
["Output tokens", fmtInt(totalOutput)],
|
|
@@ -517,10 +563,10 @@ export function renderDashboard(data) {
|
|
|
517
563
|
`<div class="usage-cards">${cardsHtml}</div>` +
|
|
518
564
|
`<p class="usage-note">Hover a request for exact values, ` +
|
|
519
565
|
`select a legend item to hide a series, and use PNG to save a chart. Event markers show ` +
|
|
520
|
-
`compactions, model or thinking changes.</p>` +
|
|
566
|
+
`compactions, model or thinking changes. Some providers report cache reads but not cache writes.</p>` +
|
|
521
567
|
`<div class="usage-charts">${charts.join("")}</div>` +
|
|
522
568
|
renderMissPanel(analyzeMisses(requests, events)) +
|
|
523
|
-
renderStreakPanel(analyzeStreaks(requests)) +
|
|
569
|
+
renderStreakPanel(analyzeStreaks(requests, events)) +
|
|
524
570
|
`<div class="usage-details">` +
|
|
525
571
|
`<div class="usage-panel"><h2>Requests</h2><div class="usage-table-wrap"><table>` +
|
|
526
572
|
`<thead><tr><th>#</th><th>Time</th><th>Provider</th><th>Response Provider</th><th>Model</th>` +
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Deterministic test cases for analyzeStreaks / analyzeMisses / parseEntries.
|
|
2
2
|
// Run: node --experimental-strip-types tests/run-tests.mjs
|
|
3
|
-
import { parseEntries, analyzeStreaks, analyzeMisses } from "../render.ts";
|
|
3
|
+
import { parseEntries, analyzeStreaks, analyzeMisses, lineChartForTest } from "../render.ts";
|
|
4
4
|
|
|
5
5
|
let pass = 0, fail = 0;
|
|
6
6
|
function check(name, cond, detail = "") {
|
|
@@ -10,9 +10,10 @@ function check(name, cond, detail = "") {
|
|
|
10
10
|
|
|
11
11
|
// helpers -------------------------------------------------------------
|
|
12
12
|
const T0 = "2026-01-01T00:00:00Z";
|
|
13
|
-
function req({ cached, fresh, write = 0, out = 100, min = 0, provider = "p", model = "m", content = [] }) {
|
|
13
|
+
function req({ cached, fresh, write = 0, write1h = 0, out = 100, min = 0, provider = "p", model = "m", api = "openai-responses", content = [] }) {
|
|
14
14
|
return { type: "message", timestamp: `2026-01-01T00:${String(min).padStart(2, "0")}:00Z`,
|
|
15
|
-
message: { role: "assistant", provider, model, stopReason: "stop", content,
|
|
15
|
+
message: { role: "assistant", provider, model, api, stopReason: "stop", content,
|
|
16
|
+
usage: { input: fresh, cacheRead: cached, cacheWrite: write, cacheWrite1h: write1h, output: out } } };
|
|
16
17
|
}
|
|
17
18
|
function toolResult(text, tool = "bash") {
|
|
18
19
|
return { type: "message", message: { role: "toolResult", toolName: tool, content: [{ type: "text", text }] } };
|
|
@@ -120,10 +121,66 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
|
|
|
120
121
|
req({ cached: 5000, fresh: 15000, min: 40 }), // 39min gap same model, hit .25
|
|
121
122
|
];
|
|
122
123
|
const m = analyzeMisses(parseEntries(entries).requests, parseEntries(entries).events);
|
|
123
|
-
check("H 39min gap + low hit -> TTL expiry", m.misses.length === 1 && m.misses[0].causes.some(([c]) => c === "TTL expiry"),
|
|
124
|
+
check("H 39min gap + low hit -> possible TTL expiry", m.misses.length === 1 && m.misses[0].causes.some(([c]) => c === "possible TTL expiry"),
|
|
124
125
|
JSON.stringify(m.misses.map((x) => x.causes)));
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// H2. long idle gap with retained prefix is new input, not TTL expiry -------
|
|
129
|
+
{
|
|
130
|
+
const entries = [
|
|
131
|
+
req({ cached: 0, fresh: 14000, min: 1 }),
|
|
132
|
+
toolResult("x".repeat(80000), "read"),
|
|
133
|
+
req({ cached: 13824, fresh: 20000, min: 40 }),
|
|
134
|
+
];
|
|
135
|
+
const d = parseEntries(entries);
|
|
136
|
+
const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
|
|
137
|
+
check("H2 long gap with 98% prefix reuse -> new input, not TTL",
|
|
138
|
+
two && two.causes.some(([cause]) => cause === "new input") && !two.causes.some(([cause]) => cause === "possible TTL expiry"),
|
|
139
|
+
JSON.stringify(two && two.causes));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// H3. Anthropic 1h writes suppress the 5min TTL heuristic -----------------
|
|
143
|
+
{
|
|
144
|
+
const entries = [
|
|
145
|
+
req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
|
|
146
|
+
req({ cached: 5000, fresh: 15000, min: 40, api: "anthropic-messages" }),
|
|
147
|
+
];
|
|
148
|
+
const d = parseEntries(entries);
|
|
149
|
+
const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
|
|
150
|
+
check("H3 39min gap after 1h write -> rebuild, not TTL expiry",
|
|
151
|
+
two && two.causes.some(([cause]) => cause === "cache rebuild") && !two.causes.some(([cause]) => cause === "possible TTL expiry"),
|
|
152
|
+
JSON.stringify(two && two.causes));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// H4. 1h retention survives intervening read-only requests -----------------
|
|
156
|
+
{
|
|
157
|
+
const entries = [
|
|
158
|
+
req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
|
|
159
|
+
req({ cached: 50000, fresh: 2000, min: 2, api: "anthropic-messages" }),
|
|
160
|
+
req({ cached: 5000, fresh: 15000, min: 40, api: "anthropic-messages" }),
|
|
161
|
+
];
|
|
162
|
+
const d = parseEntries(entries);
|
|
163
|
+
const three = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 3);
|
|
164
|
+
check("H4 inherited 1h window -> rebuild, not TTL expiry",
|
|
165
|
+
d.requests[1].cacheTtlMinutes === 60 && three && three.causes.some(([cause]) => cause === "cache rebuild") &&
|
|
166
|
+
!three.causes.some(([cause]) => cause === "possible TTL expiry"),
|
|
167
|
+
JSON.stringify({ ttl: d.requests[1].cacheTtlMinutes, causes: three && three.causes }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// H5. short-lived replacement clears an older 1h retention window ----------
|
|
171
|
+
{
|
|
172
|
+
const entries = [
|
|
173
|
+
req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
|
|
174
|
+
req({ cached: 0, fresh: 2000, write: 50000, min: 2, api: "anthropic-messages" }),
|
|
175
|
+
req({ cached: 5000, fresh: 15000, min: 12, api: "anthropic-messages" }),
|
|
176
|
+
];
|
|
177
|
+
const d = parseEntries(entries);
|
|
178
|
+
const three = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 3);
|
|
179
|
+
check("H5 short replacement resets TTL to 5min",
|
|
180
|
+
d.requests[1].cacheTtlMinutes === 5 && three && three.causes.some(([cause]) => cause === "possible TTL expiry"),
|
|
181
|
+
JSON.stringify({ ttl: d.requests[1].cacheTtlMinutes, causes: three && three.causes }));
|
|
182
|
+
}
|
|
183
|
+
|
|
127
184
|
// I. cache rebuild (prev turn was a miss, same model) -------------------
|
|
128
185
|
{
|
|
129
186
|
const entries = [
|
|
@@ -136,7 +193,38 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
|
|
|
136
193
|
check("I request2 -> cache rebuild (prev lost cache)", two && two.causes.some(([c]) => c === "cache rebuild"), JSON.stringify(m.misses.map((x) => [x.number, x.causes.map((y) => y[0])])));
|
|
137
194
|
}
|
|
138
195
|
|
|
139
|
-
//
|
|
196
|
+
// I2. cold start followed by large tool output keeps the prior prefix -------
|
|
197
|
+
{
|
|
198
|
+
const entries = [
|
|
199
|
+
req({ cached: 0, fresh: 14080, min: 1 }),
|
|
200
|
+
toolResult("a".repeat(30142), "fetch_content"),
|
|
201
|
+
toolResult("b".repeat(50662), "read"),
|
|
202
|
+
toolResult("c".repeat(30719), "read"),
|
|
203
|
+
req({ cached: 13824, fresh: 27347, min: 2 }),
|
|
204
|
+
];
|
|
205
|
+
const d = parseEntries(entries);
|
|
206
|
+
const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
|
|
207
|
+
check("I2 request2 retained 98% prior prefix -> new input, not rebuild",
|
|
208
|
+
two && two.causes.some(([c]) => c === "new input") && !two.causes.some(([c]) => c === "cache rebuild"),
|
|
209
|
+
JSON.stringify(two && two.causes));
|
|
210
|
+
check("I2 new-input detail includes estimated tool tokens",
|
|
211
|
+
two && two.causes.some(([, detail]) => detail.includes("27,881 estimated tool-output tokens")),
|
|
212
|
+
JSON.stringify(two && two.causes));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// I3. OpenAI previous fresh suffix remains part of reusable prefix --------
|
|
216
|
+
{
|
|
217
|
+
const entries = [
|
|
218
|
+
req({ cached: 80000, fresh: 20000, min: 1 }),
|
|
219
|
+
req({ cached: 45000, fresh: 55000, min: 2 }),
|
|
220
|
+
];
|
|
221
|
+
const d = parseEntries(entries);
|
|
222
|
+
const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
|
|
223
|
+
check("I3 OpenAI 45% of prior total prompt -> rebuild",
|
|
224
|
+
two && two.causes.some(([cause]) => cause === "cache rebuild"), JSON.stringify(two && two.causes));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// J. trailing event stays after the final request + tool trace ------------
|
|
140
228
|
{
|
|
141
229
|
const entries = [
|
|
142
230
|
req({ cached: 90000, fresh: 10000, min: 1, content: [{ type: "toolCall", tool: "bash", name: "bash" }] }),
|
|
@@ -148,7 +236,9 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
|
|
|
148
236
|
];
|
|
149
237
|
const d = parseEntries(entries);
|
|
150
238
|
const lastEv = d.events[d.events.length - 1];
|
|
151
|
-
check("J trailing compaction
|
|
239
|
+
check("J trailing compaction remains after request 3", lastEv.kind === "compaction" && lastEv.requestNumber === 4, JSON.stringify(lastEv));
|
|
240
|
+
const chart = lineChartForTest("events", [["cached", [1, 2, 3]]], { events: d.events });
|
|
241
|
+
check("J trailing compaction is not drawn as if it preceded request 3", !chart.includes("usage-event-compaction"));
|
|
152
242
|
const s = analyzeStreaks(d.requests);
|
|
153
243
|
check("J streak tool trace has bash~3000 + read~1000 tokens",
|
|
154
244
|
s.length === 1 && s[0].tools.get("bash") === 3000 && s[0].tools.get("read") === 1000,
|
|
@@ -167,13 +257,30 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
|
|
|
167
257
|
s.length === 1 && s[0].hits.length === 4, JSON.stringify(s.map(x => x.hits.length)));
|
|
168
258
|
}
|
|
169
259
|
|
|
170
|
-
// M.
|
|
260
|
+
// M. cacheWrite is excluded from the read-hit streak metric --------------
|
|
171
261
|
{
|
|
172
262
|
const entries = Array.from({ length: 3 }, (_, i) =>
|
|
173
|
-
req({ cached: 5000, fresh: 2000, write: 50000, min: i + 1 })); //
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
263
|
+
req({ cached: 5000, fresh: 2000, write: 50000, min: i + 1, api: "anthropic-messages" })); // read hit 8.8%
|
|
264
|
+
const d = parseEntries(entries);
|
|
265
|
+
const s = analyzeStreaks(d.requests);
|
|
266
|
+
const m = analyzeMisses(d.requests, d.events);
|
|
267
|
+
check("M cacheWrite requests break append-heavy streaks", s.length === 0, JSON.stringify(s));
|
|
268
|
+
check("M repeated writes with only 9% prior-prefix reads are rebuilds",
|
|
269
|
+
m.misses.length === 3 && m.misses.slice(1).every((miss) => miss.causes.some(([cause]) => cause === "cache rebuild")),
|
|
270
|
+
JSON.stringify(m.misses.map((miss) => miss.causes)));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// M2. retained Anthropic prefix plus a new write is cache extension --------
|
|
274
|
+
{
|
|
275
|
+
const entries = [
|
|
276
|
+
req({ cached: 50000, fresh: 2000, min: 1, api: "anthropic-messages" }),
|
|
277
|
+
req({ cached: 50000, fresh: 2000, write: 60000, min: 2, api: "anthropic-messages" }),
|
|
278
|
+
];
|
|
279
|
+
const d = parseEntries(entries);
|
|
280
|
+
const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
|
|
281
|
+
check("M2 retained prefix plus write -> cache write, not rebuild",
|
|
282
|
+
two && two.causes.some(([cause]) => cause === "cache write") && !two.causes.some(([cause]) => cause === "cache rebuild"),
|
|
283
|
+
JSON.stringify(two && two.causes));
|
|
177
284
|
}
|
|
178
285
|
|
|
179
286
|
// N. tiny prefix (fresh>=800 but prompt<4000) must not streak ---------------
|
|
@@ -183,6 +290,30 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
|
|
|
183
290
|
check("N tiny prefix (<4k) -> no streak even though fresh>=800", s.length === 0, `got ${s.length}`);
|
|
184
291
|
}
|
|
185
292
|
|
|
293
|
+
// O. model switch must split mid-band streaks immediately ----------------
|
|
294
|
+
{
|
|
295
|
+
const entries = [
|
|
296
|
+
...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 1, model: "a" })),
|
|
297
|
+
...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 4, model: "b" })),
|
|
298
|
+
];
|
|
299
|
+
const s = analyzeStreaks(parseEntries(entries).requests);
|
|
300
|
+
check("O model switch splits streaks into 3 + 3",
|
|
301
|
+
s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// P. compaction must split mid-band streaks -------------------------------
|
|
305
|
+
{
|
|
306
|
+
const entries = [
|
|
307
|
+
...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 1 })),
|
|
308
|
+
{ type: "compaction", timestamp: "2026-01-01T00:03:30Z" },
|
|
309
|
+
...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 4 })),
|
|
310
|
+
];
|
|
311
|
+
const d = parseEntries(entries);
|
|
312
|
+
const s = analyzeStreaks(d.requests, d.events);
|
|
313
|
+
check("P compaction splits streaks into 3 + 3",
|
|
314
|
+
s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
|
|
315
|
+
}
|
|
316
|
+
|
|
186
317
|
// K. degenerate inputs ---------------------------------------------------
|
|
187
318
|
{
|
|
188
319
|
check("K empty session -> no crash, no streaks", analyzeStreaks(parseEntries([]).requests).length === 0);
|