@animalabs/membrane 0.5.79 → 0.5.80
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/cache-keepalive.d.ts +115 -0
- package/dist/cache-keepalive.d.ts.map +1 -0
- package/dist/cache-keepalive.js +0 -0
- package/dist/cache-keepalive.js.map +1 -0
- package/dist/cache-keepalive.test.d.ts +2 -0
- package/dist/cache-keepalive.test.d.ts.map +1 -0
- package/dist/cache-keepalive.test.js +206 -0
- package/dist/cache-keepalive.test.js.map +1 -0
- package/dist/floating-cache-marker.test.d.ts +2 -0
- package/dist/floating-cache-marker.test.d.ts.map +1 -0
- package/dist/floating-cache-marker.test.js +242 -0
- package/dist/floating-cache-marker.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/membrane.d.ts +7 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +114 -5
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +11 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +22 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/types/config.d.ts +5 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/request.d.ts +13 -0
- package/dist/types/request.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/cache-keepalive.test.ts +244 -0
- package/src/cache-keepalive.ts +385 -0
- package/src/floating-cache-marker.test.ts +261 -0
- package/src/index.ts +13 -0
- package/src/membrane.ts +107 -5
- package/src/providers/anthropic.ts +45 -2
- package/src/types/config.ts +6 -0
- package/src/types/request.ts +14 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// Prompt-cache keepalive for the Anthropic direct API.
|
|
2
|
+
//
|
|
3
|
+
// WHY
|
|
4
|
+
// ---
|
|
5
|
+
// Anthropic prompt cache entries expire on a TTL (1h for `cache_control.ttl:
|
|
6
|
+
// '1h'`), but **reading an entry restarts its clock** — the docs say the cache
|
|
7
|
+
// "is refreshed for no additional cost each time the cached content is used",
|
|
8
|
+
// and the lifetime is measured from the start of the request that *writes or
|
|
9
|
+
// reads* the entry. Verified empirically 2026-08-22: a 5m entry, poked with a
|
|
10
|
+
// `max_tokens: 0` request every 4 minutes, was still served as a pure read at
|
|
11
|
+
// t+12m (2.4x its nominal TTL), every poke reporting create=0 / read=6617.
|
|
12
|
+
//
|
|
13
|
+
// So an idle agent's context can be held warm indefinitely at cache-READ price
|
|
14
|
+
// (0.1x input) instead of paying a cache-WRITE (2x input) on its next wake.
|
|
15
|
+
// For a ~500k-token resident that is the difference between ~$0.50 and ~$10.00
|
|
16
|
+
// per wake. Measured on fable-cm's 11-day log (2026-08-11..22): 49.7M tokens of
|
|
17
|
+
// cache_creation occurred on turns that followed a >1h idle gap — $944 of write
|
|
18
|
+
// premium that a keepalive converts into ~$308 of reads.
|
|
19
|
+
//
|
|
20
|
+
// HOW
|
|
21
|
+
// ---
|
|
22
|
+
// We snapshot the exact wire request of each real call and replay it verbatim
|
|
23
|
+
// with `max_tokens: 0`, which runs prefill only: content `[]`, stop_reason
|
|
24
|
+
// `max_tokens`, zero output tokens billed, and the cache entry refreshed.
|
|
25
|
+
//
|
|
26
|
+
// ⚠️ THE REPLAY MUST BE BYTE-IDENTICAL ABOVE THE LAST BREAKPOINT.
|
|
27
|
+
// Prompt caching is a prefix match, and the API's invalidation hierarchy means
|
|
28
|
+
// some innocent-looking "normalizations" silently turn a 0.1x read into a 2x
|
|
29
|
+
// write. Verified the hard way on 2026-08-22: replaying with
|
|
30
|
+
// `thinking: {type:'disabled'}` instead of the request's own
|
|
31
|
+
// `thinking: {type:'adaptive'}` produced create=5081 / read=0 — a full rewrite,
|
|
32
|
+
// reported as a perfectly successful call. That failure is invisible unless you
|
|
33
|
+
// check the usage numbers, so `refresh()` below checks them on every single
|
|
34
|
+
// poke and disables the lineage rather than quietly burning 20x.
|
|
35
|
+
//
|
|
36
|
+
// Hence: we never rewrite the snapshot. We change `max_tokens` (not part of the
|
|
37
|
+
// cache key) and drop `stream` (a transport concern), and nothing else. Any
|
|
38
|
+
// request shape that can't tolerate `max_tokens: 0` is skipped outright rather
|
|
39
|
+
// than "fixed up" — see `ineligibleReason()`.
|
|
40
|
+
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
|
|
43
|
+
/** Minimal shape we need back from a keepalive send. */
|
|
44
|
+
export interface KeepaliveUsage {
|
|
45
|
+
// The SDK types these as `number | null`, and null is meaningfully different
|
|
46
|
+
// from 0 here: null means the field was absent (we learned nothing), 0 means
|
|
47
|
+
// the API told us nothing was read. Both are treated as "not a read" below.
|
|
48
|
+
cache_read_input_tokens?: number | null;
|
|
49
|
+
cache_creation_input_tokens?: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type KeepaliveSend = (
|
|
53
|
+
wire: Record<string, unknown>,
|
|
54
|
+
headers: Record<string, string> | undefined,
|
|
55
|
+
) => Promise<{ usage?: KeepaliveUsage }>;
|
|
56
|
+
|
|
57
|
+
export type KeepaliveLane = 'stream' | 'complete';
|
|
58
|
+
|
|
59
|
+
export type KeepaliveEvent =
|
|
60
|
+
| { type: 'refreshed'; key: string; lane: KeepaliveLane; readTokens: number; idleMs: number }
|
|
61
|
+
| { type: 'ineffective'; key: string; reason: string; readTokens: number; writeTokens: number }
|
|
62
|
+
| { type: 'skipped'; key: string; reason: string }
|
|
63
|
+
| { type: 'error'; key: string; error: string; consecutive: number }
|
|
64
|
+
| { type: 'disabled'; reason: string }
|
|
65
|
+
| { type: 'expired'; key: string; idleMs: number };
|
|
66
|
+
|
|
67
|
+
export interface CacheKeepaliveConfig {
|
|
68
|
+
/** Master switch. Default true. */
|
|
69
|
+
enabled?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Stop refreshing once the last REAL request is this old. Keepalive pokes do
|
|
72
|
+
* not extend this — otherwise an agent that never speaks again would be kept
|
|
73
|
+
* warm forever. Default 24h.
|
|
74
|
+
*/
|
|
75
|
+
maxIdleMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Refresh once the entry hasn't been touched for this long. Must be < the
|
|
78
|
+
* cache TTL, with margin: the TTL clock starts at the *start* of the request,
|
|
79
|
+
* and a long streaming turn can itself eat minutes. Default 45m against a 1h
|
|
80
|
+
* TTL leaves 15m of headroom.
|
|
81
|
+
*/
|
|
82
|
+
refreshAfterMs?: number;
|
|
83
|
+
/** Timer cadence. Default 5m. */
|
|
84
|
+
checkIntervalMs?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Which lanes to keep warm. Default ['stream'] — the primary/voice lane.
|
|
87
|
+
* The aux ('complete') lane is measured to do no prompt caching at all today
|
|
88
|
+
* (fable-cm: 382 aux calls, every one create=0/read=0), so warming it would
|
|
89
|
+
* poke a cache entry that does not exist.
|
|
90
|
+
*/
|
|
91
|
+
lanes?: KeepaliveLane[];
|
|
92
|
+
/** LRU cap on tracked lineages, to bound memory. Each holds a full wire
|
|
93
|
+
* request (~1.5MB for a 500k-token resident). Default 4. */
|
|
94
|
+
maxLineages?: number;
|
|
95
|
+
/** Consecutive send failures before the whole keepalive disables itself. */
|
|
96
|
+
maxConsecutiveErrors?: number;
|
|
97
|
+
/**
|
|
98
|
+
* How many times a lineage may come back as a WRITE instead of a read before
|
|
99
|
+
* we stop poking it. A lineage whose prefix churns every turn cannot be kept
|
|
100
|
+
* warm, and paying 2x to discover that repeatedly is the worst outcome.
|
|
101
|
+
*/
|
|
102
|
+
maxIneffective?: number;
|
|
103
|
+
onEvent?: (event: KeepaliveEvent) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface Lineage {
|
|
107
|
+
wire: Record<string, unknown>;
|
|
108
|
+
headers: Record<string, string> | undefined;
|
|
109
|
+
lane: KeepaliveLane;
|
|
110
|
+
/** Last real (non-keepalive) request. Bounds the keepalive window. */
|
|
111
|
+
lastRealAt: number;
|
|
112
|
+
/** Last time the entry was touched by anything, real or keepalive. */
|
|
113
|
+
lastTouchAt: number;
|
|
114
|
+
ineffective: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const DEFAULTS = {
|
|
118
|
+
enabled: true,
|
|
119
|
+
maxIdleMs: 24 * 60 * 60 * 1000,
|
|
120
|
+
refreshAfterMs: 45 * 60 * 1000,
|
|
121
|
+
checkIntervalMs: 5 * 60 * 1000,
|
|
122
|
+
lanes: ['stream'] as KeepaliveLane[],
|
|
123
|
+
maxLineages: 4,
|
|
124
|
+
maxConsecutiveErrors: 3,
|
|
125
|
+
maxIneffective: 2,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reasons a request shape cannot be safely replayed as `max_tokens: 0`.
|
|
130
|
+
*
|
|
131
|
+
* Each of these is either rejected outright by the API, or — worse — would
|
|
132
|
+
* require editing the request in a way that moves the cache-invalidation
|
|
133
|
+
* boundary. Skipping is always cheaper than guessing.
|
|
134
|
+
*/
|
|
135
|
+
export function ineligibleReason(wire: Record<string, unknown>): string | null {
|
|
136
|
+
const thinking = wire.thinking as { type?: string } | undefined;
|
|
137
|
+
// `max_tokens: 0` is rejected with thinking.type 'enabled', and we must not
|
|
138
|
+
// "fix" that by disabling thinking — toggling thinking invalidates the
|
|
139
|
+
// messages cache (measured: create=5081/read=0).
|
|
140
|
+
if (thinking?.type === 'enabled') return 'legacy-thinking-budget';
|
|
141
|
+
|
|
142
|
+
const toolChoice = wire.tool_choice as { type?: string } | undefined;
|
|
143
|
+
// Rejected with max_tokens: 0, and tool_choice changes invalidate the
|
|
144
|
+
// messages cache, so we cannot substitute 'auto'.
|
|
145
|
+
if (toolChoice?.type === 'tool' || toolChoice?.type === 'any') return 'forced-tool-choice';
|
|
146
|
+
|
|
147
|
+
const outputConfig = wire.output_config as { format?: unknown } | undefined;
|
|
148
|
+
if (outputConfig?.format) return 'structured-output';
|
|
149
|
+
|
|
150
|
+
// Only the 1h cache is worth a background timer. A 5m entry would need a poke
|
|
151
|
+
// every ~4 minutes; at 0.1x of a large prefix that costs more than it saves.
|
|
152
|
+
const markers = scanCacheMarkers(wire);
|
|
153
|
+
if (!markers.any) return 'no-cache-breakpoint';
|
|
154
|
+
if (!markers.oneHour) return 'no-1h-breakpoint';
|
|
155
|
+
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Walk system + tools + messages for cache_control markers.
|
|
161
|
+
*
|
|
162
|
+
* This runs on every outbound request, and `messages` on a large resident is
|
|
163
|
+
* megabytes of blocks — so it short-circuits the moment it finds a 1h marker,
|
|
164
|
+
* which is the answer in the overwhelmingly common case.
|
|
165
|
+
*/
|
|
166
|
+
function scanCacheMarkers(wire: Record<string, unknown>): { any: boolean; oneHour: boolean } {
|
|
167
|
+
let any = false;
|
|
168
|
+
let oneHour = false;
|
|
169
|
+
|
|
170
|
+
const visit = (node: unknown): void => {
|
|
171
|
+
if (oneHour) return; // nothing left to learn
|
|
172
|
+
if (Array.isArray(node)) {
|
|
173
|
+
for (const item of node) {
|
|
174
|
+
visit(item);
|
|
175
|
+
if (oneHour) return;
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!node || typeof node !== 'object') return;
|
|
180
|
+
const obj = node as Record<string, unknown>;
|
|
181
|
+
const cc = obj.cache_control as { ttl?: string } | undefined;
|
|
182
|
+
if (cc && typeof cc === 'object') {
|
|
183
|
+
any = true;
|
|
184
|
+
if ((cc.ttl ?? '5m') === '1h') {
|
|
185
|
+
oneHour = true;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (obj.content) visit(obj.content);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
visit(wire.system);
|
|
193
|
+
visit(wire.tools);
|
|
194
|
+
visit(wire.messages);
|
|
195
|
+
return { any, oneHour };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Identity of a cache lineage: model + system + tools. This is exactly the root
|
|
200
|
+
* of the cached prefix, so two agents in one process, or an agent's primary vs
|
|
201
|
+
* aux lane, land in different buckets automatically.
|
|
202
|
+
*/
|
|
203
|
+
export function lineageKey(wire: Record<string, unknown>): string {
|
|
204
|
+
const h = createHash('sha256');
|
|
205
|
+
h.update(String(wire.model ?? ''));
|
|
206
|
+
h.update('');
|
|
207
|
+
h.update(JSON.stringify(wire.system ?? null));
|
|
208
|
+
h.update('');
|
|
209
|
+
h.update(JSON.stringify(wire.tools ?? null));
|
|
210
|
+
return h.digest('hex').slice(0, 16);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export class CacheKeepalive {
|
|
214
|
+
private lineages = new Map<string, Lineage>();
|
|
215
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
216
|
+
private ticking = false;
|
|
217
|
+
private consecutiveErrors = 0;
|
|
218
|
+
private stopped = false;
|
|
219
|
+
private readonly cfg: Required<Omit<CacheKeepaliveConfig, 'onEvent'>> & {
|
|
220
|
+
onEvent?: (event: KeepaliveEvent) => void;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
constructor(private readonly send: KeepaliveSend, config: CacheKeepaliveConfig = {}) {
|
|
224
|
+
this.cfg = { ...DEFAULTS, ...config };
|
|
225
|
+
if (!this.cfg.enabled) this.stopped = true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Record a real outbound request. Cheap; called on every LLM call. */
|
|
229
|
+
record(
|
|
230
|
+
wire: Record<string, unknown>,
|
|
231
|
+
headers: Record<string, string> | undefined,
|
|
232
|
+
lane: KeepaliveLane,
|
|
233
|
+
): void {
|
|
234
|
+
if (this.stopped) return;
|
|
235
|
+
if (!this.cfg.lanes.includes(lane)) return;
|
|
236
|
+
|
|
237
|
+
const reason = ineligibleReason(wire);
|
|
238
|
+
const key = lineageKey(wire);
|
|
239
|
+
if (reason) {
|
|
240
|
+
// Drop any stale snapshot: the shape changed and is no longer warmable.
|
|
241
|
+
if (this.lineages.delete(key)) this.emit({ type: 'skipped', key, reason });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
const existing = this.lineages.get(key);
|
|
247
|
+
// Re-insert to refresh LRU position.
|
|
248
|
+
this.lineages.delete(key);
|
|
249
|
+
this.lineages.set(key, {
|
|
250
|
+
wire,
|
|
251
|
+
headers,
|
|
252
|
+
lane,
|
|
253
|
+
lastRealAt: now,
|
|
254
|
+
lastTouchAt: now,
|
|
255
|
+
ineffective: existing?.ineffective ?? 0,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
while (this.lineages.size > this.cfg.maxLineages) {
|
|
259
|
+
const oldest = this.lineages.keys().next().value as string | undefined;
|
|
260
|
+
if (oldest === undefined) break;
|
|
261
|
+
this.lineages.delete(oldest);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.ensureTimer();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private ensureTimer(): void {
|
|
268
|
+
if (this.timer || this.stopped) return;
|
|
269
|
+
this.timer = setInterval(() => { void this.tick(); }, this.cfg.checkIntervalMs);
|
|
270
|
+
// Never hold the process open for a cache poke.
|
|
271
|
+
(this.timer as unknown as { unref?: () => void }).unref?.();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async tick(): Promise<void> {
|
|
275
|
+
if (this.ticking || this.stopped) return;
|
|
276
|
+
this.ticking = true;
|
|
277
|
+
try {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
for (const [key, lin] of [...this.lineages]) {
|
|
280
|
+
if (this.stopped) break;
|
|
281
|
+
// The keepalive window is measured from the last REAL request, so pokes
|
|
282
|
+
// can never extend their own mandate.
|
|
283
|
+
if (now - lin.lastRealAt >= this.cfg.maxIdleMs) {
|
|
284
|
+
this.lineages.delete(key);
|
|
285
|
+
this.emit({ type: 'expired', key, idleMs: now - lin.lastRealAt });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
// Idle-gated, not blind: if real traffic already touched the entry
|
|
289
|
+
// inside the window, it refreshed the TTL for free and we do nothing.
|
|
290
|
+
// This is what keeps a busy agent's keepalive cost at ~zero.
|
|
291
|
+
if (now - lin.lastTouchAt < this.cfg.refreshAfterMs) continue;
|
|
292
|
+
await this.refresh(key, lin);
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
this.ticking = false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async refresh(key: string, lin: Lineage): Promise<void> {
|
|
300
|
+
// Only max_tokens (not part of the cache key) and stream (transport) differ
|
|
301
|
+
// from the recorded request. Nothing else is touched — see file header.
|
|
302
|
+
const payload: Record<string, unknown> = { ...lin.wire, max_tokens: 0 };
|
|
303
|
+
delete payload.stream;
|
|
304
|
+
|
|
305
|
+
const idleMs = Date.now() - lin.lastTouchAt;
|
|
306
|
+
try {
|
|
307
|
+
const res = await this.send(payload, lin.headers);
|
|
308
|
+
this.consecutiveErrors = 0;
|
|
309
|
+
|
|
310
|
+
const read = res.usage?.cache_read_input_tokens ?? 0;
|
|
311
|
+
const wrote = res.usage?.cache_creation_input_tokens ?? 0;
|
|
312
|
+
|
|
313
|
+
// The self-check. A keepalive that WRITES has not kept anything alive —
|
|
314
|
+
// it paid 2x to create a fresh entry, which is the exact failure this
|
|
315
|
+
// whole module exists to avoid. Never assume the poke worked.
|
|
316
|
+
if (read <= 0 || wrote > 0) {
|
|
317
|
+
lin.ineffective += 1;
|
|
318
|
+
this.emit({
|
|
319
|
+
type: 'ineffective',
|
|
320
|
+
key,
|
|
321
|
+
reason: wrote > 0 ? 'wrote-instead-of-read' : 'no-cache-read',
|
|
322
|
+
readTokens: read,
|
|
323
|
+
writeTokens: wrote,
|
|
324
|
+
});
|
|
325
|
+
if (lin.ineffective >= this.cfg.maxIneffective) {
|
|
326
|
+
this.lineages.delete(key);
|
|
327
|
+
} else {
|
|
328
|
+
lin.lastTouchAt = Date.now();
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
lin.ineffective = 0;
|
|
334
|
+
lin.lastTouchAt = Date.now();
|
|
335
|
+
this.emit({ type: 'refreshed', key, lane: lin.lane, readTokens: read, idleMs });
|
|
336
|
+
} catch (err) {
|
|
337
|
+
this.consecutiveErrors += 1;
|
|
338
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
339
|
+
this.emit({ type: 'error', key, error: message, consecutive: this.consecutiveErrors });
|
|
340
|
+
|
|
341
|
+
// Back this lineage off immediately rather than retrying on the next tick.
|
|
342
|
+
lin.lastTouchAt = Date.now();
|
|
343
|
+
|
|
344
|
+
// Hard breaker. A background loop that keeps firing failing requests is
|
|
345
|
+
// how fable-cm produced 1033 `400 invalid_request_error` rows in 3h on
|
|
346
|
+
// 2026-08-21 — the exact error class that also trips the agent's
|
|
347
|
+
// poison-history breaker. A keepalive must never be that loop.
|
|
348
|
+
if (this.consecutiveErrors >= this.cfg.maxConsecutiveErrors) {
|
|
349
|
+
this.stop();
|
|
350
|
+
this.emit({
|
|
351
|
+
type: 'disabled',
|
|
352
|
+
reason: `${this.consecutiveErrors} consecutive keepalive failures; last: ${message}`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private emit(event: KeepaliveEvent): void {
|
|
359
|
+
try {
|
|
360
|
+
this.cfg.onEvent?.(event);
|
|
361
|
+
} catch {
|
|
362
|
+
// Observability must never break the keepalive, nor the caller.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Snapshot for operators / tests. */
|
|
367
|
+
getStatus(): Array<{ key: string; lane: KeepaliveLane; idleMs: number; realIdleMs: number }> {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
return [...this.lineages].map(([key, l]) => ({
|
|
370
|
+
key,
|
|
371
|
+
lane: l.lane,
|
|
372
|
+
idleMs: now - l.lastTouchAt,
|
|
373
|
+
realIdleMs: now - l.lastRealAt,
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
stop(): void {
|
|
378
|
+
this.stopped = true;
|
|
379
|
+
if (this.timer) {
|
|
380
|
+
clearInterval(this.timer);
|
|
381
|
+
this.timer = null;
|
|
382
|
+
}
|
|
383
|
+
this.lineages.clear();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating cache marker: incremental prompt caching inside the native
|
|
3
|
+
* tool loop (see the doctrine block in buildNativeToolRequest).
|
|
4
|
+
*
|
|
5
|
+
* Context strategies place breakpoints once per turn at compile time; the
|
|
6
|
+
* tool loop rebuilds the request every round with an append-only suffix
|
|
7
|
+
* the strategy never saw. The float rides the newest message using only
|
|
8
|
+
* the RESIDUAL breakpoint budget — upstream markers are never displaced.
|
|
9
|
+
* Motivating incident: qa-ops 2026-08-20, ~5.3M uncached tokens in 18min
|
|
10
|
+
* from two subagents whose single marker sat at message 2 of 61.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
13
|
+
import { Membrane } from './membrane.js';
|
|
14
|
+
import type { NormalizedRequest, NormalizedMessage } from './types/index.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Fixtures
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const text = (t: string) => ({ type: 'text' as const, text: t });
|
|
21
|
+
|
|
22
|
+
const user = (t: string, bp = false): NormalizedMessage => ({
|
|
23
|
+
participant: 'User',
|
|
24
|
+
content: [text(t)],
|
|
25
|
+
...(bp ? { cacheBreakpoint: true } : {}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const assistantToolCall = (id: string): NormalizedMessage => ({
|
|
29
|
+
participant: 'Claude',
|
|
30
|
+
content: [
|
|
31
|
+
text('running a tool'),
|
|
32
|
+
{ type: 'tool_use' as const, id, name: 'shell', input: { cmd: 'ls' } },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const toolResults = (id: string, bp = false): NormalizedMessage => ({
|
|
37
|
+
participant: 'User',
|
|
38
|
+
content: [{ type: 'tool_result' as const, toolUseId: id, content: 'ok' }],
|
|
39
|
+
...(bp ? { cacheBreakpoint: true } : {}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** A turn: marked kickoff + `rounds` completed tool rounds. */
|
|
43
|
+
function turn(rounds: number, kickoffMarked = true): NormalizedMessage[] {
|
|
44
|
+
const messages: NormalizedMessage[] = [user('do the thing', kickoffMarked)];
|
|
45
|
+
for (let i = 1; i <= rounds; i++) {
|
|
46
|
+
messages.push(assistantToolCall(`t${i}`), toolResults(`t${i}`));
|
|
47
|
+
}
|
|
48
|
+
return messages;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeRequest(overrides: Partial<NormalizedRequest> = {}): NormalizedRequest {
|
|
52
|
+
return {
|
|
53
|
+
messages: [],
|
|
54
|
+
system: 'You are a test agent.',
|
|
55
|
+
config: { model: 'claude-sonnet-5', maxTokens: 128 },
|
|
56
|
+
tools: [{ name: 'shell', description: 'run a command', inputSchema: { type: 'object' } }],
|
|
57
|
+
promptCaching: true,
|
|
58
|
+
cacheTtl: '1h',
|
|
59
|
+
assistantParticipant: 'Claude',
|
|
60
|
+
...overrides,
|
|
61
|
+
} as NormalizedRequest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function build(request: NormalizedRequest, messages: NormalizedMessage[], rebuild: boolean, membrane?: Membrane) {
|
|
65
|
+
const m = membrane ?? new Membrane({ name: 'anthropic' } as any);
|
|
66
|
+
return (m as any).buildNativeToolRequest(request, messages, rebuild);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Total cache_control instances across the whole wire request. */
|
|
70
|
+
function totalMarkers(pr: any): number {
|
|
71
|
+
let n = 0;
|
|
72
|
+
pr.messages.forEach((m: any) =>
|
|
73
|
+
(Array.isArray(m.content) ? m.content : []).forEach((b: any) => { if (b.cache_control) n++; }));
|
|
74
|
+
if (Array.isArray(pr.tools)) pr.tools.forEach((t: any) => { if (t.cache_control) n++; });
|
|
75
|
+
if (Array.isArray(pr.system)) pr.system.forEach((b: any) => { if (b.cache_control) n++; });
|
|
76
|
+
return n;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** [messageIndex, blockIndex] of every message-level cache_control, plus tools/system markers. */
|
|
80
|
+
function markers(pr: any): { messages: Array<[number, number]>; onTools: boolean; onSystem: boolean } {
|
|
81
|
+
const msgs: Array<[number, number]> = [];
|
|
82
|
+
pr.messages.forEach((m: any, mi: number) => {
|
|
83
|
+
(Array.isArray(m.content) ? m.content : []).forEach((b: any, bi: number) => {
|
|
84
|
+
if (b.cache_control) msgs.push([mi, bi]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
const onTools = Array.isArray(pr.tools) && pr.tools.some((t: any) => t.cache_control);
|
|
88
|
+
const onSystem = Array.isArray(pr.system) && pr.system.some((b: any) => b.cache_control);
|
|
89
|
+
return { messages: msgs, onTools, onSystem };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const last = (pr: any) => pr.messages.length - 1;
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.restoreAllMocks();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Tests
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('floating cache marker', () => {
|
|
103
|
+
it('does not float on the turn\'s first build', () => {
|
|
104
|
+
const pr = build(makeRequest(), turn(1), false);
|
|
105
|
+
// Only the strategy's kickoff marker; the round's suffix is unmarked.
|
|
106
|
+
expect(markers(pr).messages).toEqual([[0, 0]]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('floats onto the newest message on a tool-loop rebuild', () => {
|
|
110
|
+
const pr = build(makeRequest(), turn(1), true);
|
|
111
|
+
const m = markers(pr);
|
|
112
|
+
// Kickoff marker intact + float on the final tool_result envelope.
|
|
113
|
+
expect(m.messages).toContainEqual([0, 0]);
|
|
114
|
+
expect(m.messages).toContainEqual([last(pr), 0]);
|
|
115
|
+
// Fallback stays suppressed: message markers exist.
|
|
116
|
+
expect(m.onTools).toBe(false);
|
|
117
|
+
expect(m.onSystem).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps the previous round\'s endpoint marked when budget allows', () => {
|
|
121
|
+
const pr = build(makeRequest(), turn(2), true);
|
|
122
|
+
const m = markers(pr);
|
|
123
|
+
// kickoff + previous round's results envelope + newest envelope.
|
|
124
|
+
expect(m.messages).toEqual([[0, 0], [last(pr) - 2, 0], [last(pr), 0]]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('never stacks a second marker on an already-marked final message', () => {
|
|
128
|
+
const messages = turn(2);
|
|
129
|
+
(messages[messages.length - 1] as any).cacheBreakpoint = true;
|
|
130
|
+
const pr = build(makeRequest(), messages, true);
|
|
131
|
+
const m = markers(pr);
|
|
132
|
+
const onFinal = m.messages.filter(([mi]) => mi === last(pr));
|
|
133
|
+
expect(onFinal).toHaveLength(1);
|
|
134
|
+
// Budget not consumed by the dedupe: previous endpoint still floated.
|
|
135
|
+
expect(m.messages).toContainEqual([last(pr) - 2, 0]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('withholds the float (with one warning) when upstream markers fill all 4 slots', () => {
|
|
139
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
140
|
+
const messages = turn(3);
|
|
141
|
+
// Mark every results envelope: 1 kickoff + 3 results = 4 upstream markers.
|
|
142
|
+
for (const msg of messages) {
|
|
143
|
+
if ((msg.content[0] as any).type === 'tool_result') (msg as any).cacheBreakpoint = true;
|
|
144
|
+
}
|
|
145
|
+
const membrane = new Membrane({ name: 'anthropic' } as any);
|
|
146
|
+
const pr = build(makeRequest(), messages, true, membrane);
|
|
147
|
+
expect(markers(pr).messages).toHaveLength(4);
|
|
148
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
149
|
+
// Warn-once: a second rebuild does not warn again.
|
|
150
|
+
build(makeRequest(), messages, true, membrane);
|
|
151
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('floats from the residuum left after the tools/system fallback on markerless requests', () => {
|
|
155
|
+
const pr = build(makeRequest(), turn(2, false), true);
|
|
156
|
+
const m = markers(pr);
|
|
157
|
+
// Fallback spent 2 (tools + system) → residuum 2 → newest + previous endpoint.
|
|
158
|
+
expect(m.onTools).toBe(true);
|
|
159
|
+
expect(m.onSystem).toBe(true);
|
|
160
|
+
expect(m.messages).toEqual([[last(pr) - 2, 0], [last(pr), 0]]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('steps back off a trailing thinking block', () => {
|
|
164
|
+
const messages = turn(1);
|
|
165
|
+
messages.push({
|
|
166
|
+
participant: 'Claude',
|
|
167
|
+
content: [text('partial'), { type: 'thinking' as const, thinking: 'hmm', signature: 'sig' }],
|
|
168
|
+
} as NormalizedMessage);
|
|
169
|
+
const pr = build(makeRequest(), messages, true);
|
|
170
|
+
const m = markers(pr);
|
|
171
|
+
// Marker lands on the text block, not the thinking block.
|
|
172
|
+
const finalMarks = m.messages.filter(([mi]) => mi === last(pr));
|
|
173
|
+
expect(finalMarks).toHaveLength(1);
|
|
174
|
+
const [, bi] = finalMarks[0]!;
|
|
175
|
+
expect((pr.messages[last(pr)].content[bi] as any).type).toBe('text');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('stands down entirely when the normalizer synthesized a [pending] tool_result', () => {
|
|
179
|
+
// Trailing orphan tool_use → normalizer synthesizes its [pending]
|
|
180
|
+
// result, whose bytes change when the real result lands.
|
|
181
|
+
const messages = [user('go', true), assistantToolCall('t1')];
|
|
182
|
+
const pr = build(makeRequest(), messages, true);
|
|
183
|
+
// Only the kickoff marker; nothing floated at or past the synthetic.
|
|
184
|
+
expect(markers(pr).messages).toEqual([[0, 0]]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('respects request-level opt-out', () => {
|
|
188
|
+
const pr = build(makeRequest({ floatingCacheMarker: false }), turn(1), true);
|
|
189
|
+
expect(markers(pr).messages).toEqual([[0, 0]]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('emits no cache_control at all when promptCaching is off', () => {
|
|
193
|
+
const pr = build(makeRequest({ promptCaching: false }), turn(1), true);
|
|
194
|
+
const m = markers(pr);
|
|
195
|
+
expect(m.messages).toEqual([]);
|
|
196
|
+
expect(m.onTools).toBe(false);
|
|
197
|
+
expect(m.onSystem).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('counts pre-marked system blocks against the residual budget (never exceeds 4 on the wire)', () => {
|
|
201
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
202
|
+
// System is a block array with one block already carrying cache_control —
|
|
203
|
+
// invisible to the running message tally, but a real wire marker.
|
|
204
|
+
const premarkedSystem = [
|
|
205
|
+
{ type: 'text', text: 'You are a test agent.', cache_control: { type: 'ephemeral' } },
|
|
206
|
+
{ type: 'text', text: 'Addendum.' },
|
|
207
|
+
] as any;
|
|
208
|
+
// 3 message markers + pre-marked system = 4 on the wire: no room to float.
|
|
209
|
+
const full = turn(2);
|
|
210
|
+
for (const msg of full) {
|
|
211
|
+
if ((msg.content[0] as any).type === 'tool_result') (msg as any).cacheBreakpoint = true;
|
|
212
|
+
}
|
|
213
|
+
const prFull = build(makeRequest({ system: premarkedSystem }), full, true);
|
|
214
|
+
expect(totalMarkers(prFull)).toBe(4);
|
|
215
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
216
|
+
// 2 message markers + pre-marked system = 3: exactly one slot left — the
|
|
217
|
+
// float takes the newest message and stops.
|
|
218
|
+
const partial = turn(2);
|
|
219
|
+
(partial[2] as any).cacheBreakpoint = true; // first round's results envelope
|
|
220
|
+
const prPartial = build(makeRequest({ system: premarkedSystem }), partial, true);
|
|
221
|
+
expect(totalMarkers(prPartial)).toBe(4);
|
|
222
|
+
expect(markers(prPartial).messages).toContainEqual([last(prPartial), 0]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('counts an overlapping block-level + message-level marker once (one physical marker)', () => {
|
|
226
|
+
// A stale block-level cache_control on the very block the message's
|
|
227
|
+
// cacheBreakpoint lands on is ONE wire marker, not two. Two such
|
|
228
|
+
// messages must leave residuum 2, not 0.
|
|
229
|
+
const staleMarked = (t: string): NormalizedMessage => ({
|
|
230
|
+
participant: 'User',
|
|
231
|
+
content: [{ type: 'text', text: t, cache_control: { type: 'ephemeral' } } as any],
|
|
232
|
+
cacheBreakpoint: true,
|
|
233
|
+
});
|
|
234
|
+
// Results envelope whose message-level breakpoint lands on a text block
|
|
235
|
+
// that already carries stale cache_control — tool pairing stays intact.
|
|
236
|
+
const overlapResults = (id: string): NormalizedMessage => ({
|
|
237
|
+
participant: 'User',
|
|
238
|
+
content: [
|
|
239
|
+
{ type: 'tool_result' as const, toolUseId: id, content: 'ok' },
|
|
240
|
+
{ type: 'text', text: 'operator note', cache_control: { type: 'ephemeral' } } as any,
|
|
241
|
+
],
|
|
242
|
+
cacheBreakpoint: true,
|
|
243
|
+
});
|
|
244
|
+
const messages = [staleMarked('do the thing'),
|
|
245
|
+
assistantToolCall('t1'), overlapResults('t1'),
|
|
246
|
+
assistantToolCall('t2'), toolResults('t2')];
|
|
247
|
+
// 2 physical wire markers (the old tally saw 4 and withheld everything).
|
|
248
|
+
const pr = build(makeRequest(), messages, true);
|
|
249
|
+
const m = markers(pr);
|
|
250
|
+
// Float not withheld: newest message marked; previous endpoint already
|
|
251
|
+
// carries its own marker (dedupe, no slot spent).
|
|
252
|
+
expect(m.messages).toContainEqual([last(pr), 0]);
|
|
253
|
+
expect(totalMarkers(pr)).toBeLessThanOrEqual(4);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('floated markers carry the request cacheTtl', () => {
|
|
257
|
+
const pr = build(makeRequest(), turn(1), true);
|
|
258
|
+
const [mi, bi] = markers(pr).messages.find(([i]) => i === last(pr))!;
|
|
259
|
+
expect((pr.messages[mi].content[bi] as any).cache_control).toEqual({ type: 'ephemeral', ttl: '1h' });
|
|
260
|
+
});
|
|
261
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -24,3 +24,16 @@ export * from './formatters/index.js';
|
|
|
24
24
|
|
|
25
25
|
// Context management
|
|
26
26
|
export * from './context/index.js';
|
|
27
|
+
|
|
28
|
+
// Prompt-cache keepalive (Anthropic 1h cache)
|
|
29
|
+
export {
|
|
30
|
+
CacheKeepalive,
|
|
31
|
+
ineligibleReason as cacheKeepaliveIneligibleReason,
|
|
32
|
+
lineageKey as cacheLineageKey,
|
|
33
|
+
} from './cache-keepalive.js';
|
|
34
|
+
export type {
|
|
35
|
+
CacheKeepaliveConfig,
|
|
36
|
+
KeepaliveEvent,
|
|
37
|
+
KeepaliveLane,
|
|
38
|
+
KeepaliveSend,
|
|
39
|
+
} from './cache-keepalive.js';
|