@animalabs/membrane 0.5.79 → 0.5.81
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/context/index.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/process.d.ts +3 -1
- package/dist/context/process.d.ts.map +1 -1
- package/dist/context/process.js +230 -49
- package/dist/context/process.js.map +1 -1
- package/dist/context/types.d.ts +30 -0
- package/dist/context/types.d.ts.map +1 -1
- package/dist/context/types.js +16 -0
- package/dist/context/types.js.map +1 -1
- 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 +263 -0
- package/dist/floating-cache-marker.test.js.map +1 -0
- package/dist/formatters/anthropic-xml.d.ts +2 -0
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +57 -19
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
- package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
- package/dist/formatters/normalize-tool-pairs.js +45 -0
- package/dist/formatters/normalize-tool-pairs.js.map +1 -1
- package/dist/formatters/types.d.ts +29 -0
- package/dist/formatters/types.d.ts.map +1 -1
- 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 +142 -7
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +881 -270
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +163 -26
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +24 -1
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/gemini.d.ts +11 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +104 -82
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +2 -0
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +7 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +99 -48
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai-completions.d.ts +5 -0
- package/dist/providers/openai-completions.d.ts.map +1 -1
- package/dist/providers/openai-completions.js +107 -44
- package/dist/providers/openai-completions.js.map +1 -1
- package/dist/providers/openai-responses-api.d.ts +16 -0
- package/dist/providers/openai-responses-api.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.js +38 -10
- package/dist/providers/openai-responses-api.js.map +1 -1
- package/dist/providers/openai-responses.d.ts +2 -0
- package/dist/providers/openai-responses.d.ts.map +1 -1
- package/dist/providers/openai-responses.js +8 -1
- package/dist/providers/openai-responses.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +92 -44
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +9 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +118 -62
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/utils.d.ts +74 -2
- package/dist/providers/utils.d.ts.map +1 -1
- package/dist/providers/utils.js +184 -3
- package/dist/providers/utils.js.map +1 -1
- package/dist/registry/default-pricing.d.ts +22 -0
- package/dist/registry/default-pricing.d.ts.map +1 -1
- package/dist/registry/default-pricing.js +162 -28
- package/dist/registry/default-pricing.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/content.d.ts +13 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +26 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/provider.d.ts +50 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +13 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/types/response.d.ts +85 -4
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +6 -1
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.d.ts +82 -0
- package/dist/utils/cache-marker-budget.d.ts.map +1 -0
- package/dist/utils/cache-marker-budget.js +189 -0
- package/dist/utils/cache-marker-budget.js.map +1 -0
- package/dist/utils/cost.d.ts +22 -0
- package/dist/utils/cost.d.ts.map +1 -1
- package/dist/utils/cost.js +55 -0
- package/dist/utils/cost.js.map +1 -1
- package/dist/utils/thinking-carriers.d.ts +80 -0
- package/dist/utils/thinking-carriers.d.ts.map +1 -0
- package/dist/utils/thinking-carriers.js +115 -0
- package/dist/utils/thinking-carriers.js.map +1 -0
- package/dist/utils/tool-parser.d.ts +24 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +367 -161
- package/dist/utils/tool-parser.js.map +1 -1
- package/dist/utils/usage.d.ts +71 -0
- package/dist/utils/usage.d.ts.map +1 -0
- package/dist/utils/usage.js +161 -0
- package/dist/utils/usage.js.map +1 -0
- package/dist/yielding-stream.d.ts +8 -0
- package/dist/yielding-stream.d.ts.map +1 -1
- package/dist/yielding-stream.js +49 -3
- package/dist/yielding-stream.js.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/context/index.ts +1 -0
- package/src/context/process.ts +305 -59
- package/src/context/types.ts +38 -0
- package/src/floating-cache-marker.test.ts +285 -0
- package/src/formatters/anthropic-xml.ts +62 -20
- package/src/formatters/normalize-tool-pairs.ts +49 -0
- package/src/formatters/types.ts +32 -0
- package/src/index.ts +13 -0
- package/src/membrane.ts +1031 -279
- package/src/providers/anthropic.ts +205 -20
- package/src/providers/bedrock.ts +25 -1
- package/src/providers/gemini.ts +125 -87
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-compatible.ts +102 -45
- package/src/providers/openai-completions.ts +112 -46
- package/src/providers/openai-responses-api.ts +45 -15
- package/src/providers/openai-responses.ts +8 -1
- package/src/providers/openai.ts +95 -42
- package/src/providers/openrouter.ts +125 -63
- package/src/providers/utils.ts +227 -6
- package/src/registry/default-pricing.ts +165 -28
- package/src/types/config.ts +6 -0
- package/src/types/content.ts +14 -0
- package/src/types/errors.ts +28 -0
- package/src/types/index.ts +6 -0
- package/src/types/provider.ts +55 -0
- package/src/types/request.ts +14 -0
- package/src/types/response.ts +109 -8
- package/src/types/streaming.ts +8 -2
- package/src/utils/cache-marker-budget.ts +208 -0
- package/src/utils/cost.ts +64 -0
- package/src/utils/thinking-carriers.ts +125 -0
- package/src/utils/tool-parser.ts +473 -174
- package/src/utils/usage.ts +184 -0
- package/src/yielding-stream.ts +54 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CacheKeepalive,
|
|
4
|
+
ineligibleReason,
|
|
5
|
+
lineageKey,
|
|
6
|
+
type KeepaliveEvent,
|
|
7
|
+
} from './cache-keepalive.js';
|
|
8
|
+
|
|
9
|
+
/** A wire request shaped like what buildRequest() produces for a real turn. */
|
|
10
|
+
function wire(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
11
|
+
return {
|
|
12
|
+
model: 'claude-fable-5',
|
|
13
|
+
max_tokens: 32000,
|
|
14
|
+
stream: true,
|
|
15
|
+
thinking: { type: 'adaptive' },
|
|
16
|
+
system: [
|
|
17
|
+
{ type: 'text', text: 'You are a resident.', cache_control: { type: 'ephemeral', ttl: '1h' } },
|
|
18
|
+
],
|
|
19
|
+
messages: [
|
|
20
|
+
{
|
|
21
|
+
role: 'user',
|
|
22
|
+
content: [
|
|
23
|
+
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral', ttl: '1h' } },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hit = { usage: { cache_read_input_tokens: 494_000, cache_creation_input_tokens: 0 } };
|
|
32
|
+
|
|
33
|
+
describe('ineligibleReason', () => {
|
|
34
|
+
it('accepts a normal 1h-cached adaptive-thinking request', () => {
|
|
35
|
+
expect(ineligibleReason(wire())).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects legacy budget_tokens thinking (max_tokens:0 is refused, and we must not disable thinking to fix it)', () => {
|
|
39
|
+
expect(ineligibleReason(wire({ thinking: { type: 'enabled', budget_tokens: 8000 } })))
|
|
40
|
+
.toBe('legacy-thinking-budget');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects forced tool_choice, which cannot be substituted without invalidating the messages cache', () => {
|
|
44
|
+
expect(ineligibleReason(wire({ tool_choice: { type: 'any' } }))).toBe('forced-tool-choice');
|
|
45
|
+
expect(ineligibleReason(wire({ tool_choice: { type: 'tool', name: 'x' } }))).toBe('forced-tool-choice');
|
|
46
|
+
expect(ineligibleReason(wire({ tool_choice: { type: 'auto' } }))).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects structured output, which the API refuses with max_tokens:0', () => {
|
|
50
|
+
expect(ineligibleReason(wire({ output_config: { format: { type: 'json_schema' } } })))
|
|
51
|
+
.toBe('structured-output');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a request with no cache_control at all (the compression lane today)', () => {
|
|
55
|
+
const aux = wire({ system: undefined, messages: [{ role: 'user', content: 'compress this' }] });
|
|
56
|
+
expect(ineligibleReason(aux)).toBe('no-cache-breakpoint');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects a 5m-only request: poking a 5m entry every few minutes costs more than it saves', () => {
|
|
60
|
+
const short = wire({
|
|
61
|
+
system: [{ type: 'text', text: 's', cache_control: { type: 'ephemeral' } }],
|
|
62
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'h', cache_control: { type: 'ephemeral', ttl: '5m' } }] }],
|
|
63
|
+
});
|
|
64
|
+
expect(ineligibleReason(short)).toBe('no-1h-breakpoint');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('lineageKey', () => {
|
|
69
|
+
it('is stable across turns that only append messages', () => {
|
|
70
|
+
const a = wire();
|
|
71
|
+
const b = wire({ messages: [...(wire().messages as unknown[]), { role: 'assistant', content: 'hi' }] });
|
|
72
|
+
expect(lineageKey(a)).toBe(lineageKey(b));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('separates different agents, models, and tool sets', () => {
|
|
76
|
+
expect(lineageKey(wire())).not.toBe(lineageKey(wire({ model: 'claude-opus-5' })));
|
|
77
|
+
expect(lineageKey(wire())).not.toBe(
|
|
78
|
+
lineageKey(wire({ system: [{ type: 'text', text: 'different agent' }] })),
|
|
79
|
+
);
|
|
80
|
+
expect(lineageKey(wire())).not.toBe(lineageKey(wire({ tools: [{ name: 't' }] })));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('CacheKeepalive', () => {
|
|
85
|
+
beforeEach(() => vi.useFakeTimers());
|
|
86
|
+
afterEach(() => vi.useRealTimers());
|
|
87
|
+
|
|
88
|
+
const setup = (send: ReturnType<typeof vi.fn>, cfg = {}) => {
|
|
89
|
+
const events: KeepaliveEvent[] = [];
|
|
90
|
+
const ka = new CacheKeepalive(send as never, {
|
|
91
|
+
refreshAfterMs: 45 * 60_000,
|
|
92
|
+
checkIntervalMs: 5 * 60_000,
|
|
93
|
+
maxIdleMs: 24 * 60 * 60_000,
|
|
94
|
+
onEvent: (e) => events.push(e),
|
|
95
|
+
...cfg,
|
|
96
|
+
});
|
|
97
|
+
return { ka, events };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
it('does nothing while real traffic keeps the entry warm (the busy-agent case)', async () => {
|
|
101
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
102
|
+
const { ka } = setup(send);
|
|
103
|
+
// A turn every 10 minutes for 2 hours: real requests refresh the TTL for
|
|
104
|
+
// free, so the keepalive must never fire.
|
|
105
|
+
for (let i = 0; i < 12; i++) {
|
|
106
|
+
ka.record(wire(), undefined, 'stream');
|
|
107
|
+
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
108
|
+
}
|
|
109
|
+
expect(send).not.toHaveBeenCalled();
|
|
110
|
+
ka.stop();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('refreshes once the entry goes untouched past refreshAfterMs', async () => {
|
|
114
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
115
|
+
const { ka, events } = setup(send);
|
|
116
|
+
ka.record(wire(), undefined, 'stream');
|
|
117
|
+
|
|
118
|
+
await vi.advanceTimersByTimeAsync(40 * 60_000);
|
|
119
|
+
expect(send).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
122
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(events.some((e) => e.type === 'refreshed')).toBe(true);
|
|
124
|
+
ka.stop();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('replays max_tokens:0 without stream, and changes nothing else', async () => {
|
|
128
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
129
|
+
const { ka } = setup(send);
|
|
130
|
+
const original = wire();
|
|
131
|
+
ka.record(original, { 'anthropic-beta': 'x' }, 'stream');
|
|
132
|
+
await vi.advanceTimersByTimeAsync(50 * 60_000);
|
|
133
|
+
|
|
134
|
+
const call = send.mock.calls[0]!;
|
|
135
|
+
const payload = call[0] as Record<string, unknown>;
|
|
136
|
+
const headers = call[1] as Record<string, string> | undefined;
|
|
137
|
+
expect(payload.max_tokens).toBe(0);
|
|
138
|
+
expect('stream' in payload).toBe(false);
|
|
139
|
+
expect(headers).toEqual({ 'anthropic-beta': 'x' });
|
|
140
|
+
// The cache-key-bearing fields must be untouched — this is the whole point.
|
|
141
|
+
expect(payload.thinking).toEqual({ type: 'adaptive' });
|
|
142
|
+
expect(payload.system).toBe(original.system);
|
|
143
|
+
expect(payload.messages).toBe(original.messages);
|
|
144
|
+
expect(payload.model).toBe('claude-fable-5');
|
|
145
|
+
ka.stop();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('stops refreshing after maxIdleMs measured from the last REAL request', async () => {
|
|
149
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
150
|
+
const { ka, events } = setup(send, { maxIdleMs: 3 * 60 * 60_000 });
|
|
151
|
+
ka.record(wire(), undefined, 'stream');
|
|
152
|
+
|
|
153
|
+
await vi.advanceTimersByTimeAsync(3 * 60 * 60_000 + 60_000);
|
|
154
|
+
// Pokes must not extend their own mandate: ~3 refreshes, then expiry.
|
|
155
|
+
expect(events.some((e) => e.type === 'expired')).toBe(true);
|
|
156
|
+
const before = send.mock.calls.length;
|
|
157
|
+
await vi.advanceTimersByTimeAsync(6 * 60 * 60_000);
|
|
158
|
+
expect(send.mock.calls.length).toBe(before);
|
|
159
|
+
ka.stop();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('detects a poke that WROTE instead of read, and gives up on that lineage', async () => {
|
|
163
|
+
// The silent-20x failure: a successful call that created a fresh 2x entry.
|
|
164
|
+
const send = vi.fn().mockResolvedValue({
|
|
165
|
+
usage: { cache_read_input_tokens: 0, cache_creation_input_tokens: 494_000 },
|
|
166
|
+
});
|
|
167
|
+
const { ka, events } = setup(send, { maxIneffective: 2 });
|
|
168
|
+
ka.record(wire(), undefined, 'stream');
|
|
169
|
+
|
|
170
|
+
await vi.advanceTimersByTimeAsync(50 * 60_000);
|
|
171
|
+
await vi.advanceTimersByTimeAsync(50 * 60_000);
|
|
172
|
+
|
|
173
|
+
const bad = events.filter((e) => e.type === 'ineffective');
|
|
174
|
+
expect(bad.length).toBe(2);
|
|
175
|
+
expect((bad[0] as { reason: string }).reason).toBe('wrote-instead-of-read');
|
|
176
|
+
|
|
177
|
+
const after = send.mock.calls.length;
|
|
178
|
+
await vi.advanceTimersByTimeAsync(4 * 60 * 60_000);
|
|
179
|
+
expect(send.mock.calls.length).toBe(after);
|
|
180
|
+
ka.stop();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('treats an absent usage field as "not a read" rather than assuming success', async () => {
|
|
184
|
+
const send = vi.fn().mockResolvedValue({});
|
|
185
|
+
const { ka, events } = setup(send);
|
|
186
|
+
ka.record(wire(), undefined, 'stream');
|
|
187
|
+
await vi.advanceTimersByTimeAsync(50 * 60_000);
|
|
188
|
+
expect(events.some((e) => e.type === 'ineffective')).toBe(true);
|
|
189
|
+
ka.stop();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('hard-disables after consecutive errors instead of becoming a retry storm', async () => {
|
|
193
|
+
// fable-cm, 2026-08-21: 1033 `400 invalid_request_error` in 3h from a
|
|
194
|
+
// background lane. A keepalive must never be able to do that.
|
|
195
|
+
const send = vi.fn().mockRejectedValue(new Error('400 invalid_request_error'));
|
|
196
|
+
const { ka, events } = setup(send, { maxConsecutiveErrors: 3 });
|
|
197
|
+
ka.record(wire(), undefined, 'stream');
|
|
198
|
+
|
|
199
|
+
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000);
|
|
200
|
+
|
|
201
|
+
expect(send.mock.calls.length).toBe(3);
|
|
202
|
+
expect(events.some((e) => e.type === 'disabled')).toBe(true);
|
|
203
|
+
ka.stop();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('ignores the aux lane by default', async () => {
|
|
207
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
208
|
+
const { ka } = setup(send);
|
|
209
|
+
ka.record(wire(), undefined, 'complete');
|
|
210
|
+
await vi.advanceTimersByTimeAsync(2 * 60 * 60_000);
|
|
211
|
+
expect(send).not.toHaveBeenCalled();
|
|
212
|
+
ka.stop();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('evicts the oldest lineage past maxLineages to bound memory', () => {
|
|
216
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
217
|
+
const { ka } = setup(send, { maxLineages: 2 });
|
|
218
|
+
ka.record(wire({ model: 'a' }), undefined, 'stream');
|
|
219
|
+
ka.record(wire({ model: 'b' }), undefined, 'stream');
|
|
220
|
+
ka.record(wire({ model: 'c' }), undefined, 'stream');
|
|
221
|
+
expect(ka.getStatus().length).toBe(2);
|
|
222
|
+
ka.stop();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('drops a snapshot when the request shape stops being warmable', () => {
|
|
226
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
227
|
+
const { ka, events } = setup(send);
|
|
228
|
+
ka.record(wire(), undefined, 'stream');
|
|
229
|
+
expect(ka.getStatus().length).toBe(1);
|
|
230
|
+
// Same lineage (model/system/tools identical), now with forced tool choice.
|
|
231
|
+
ka.record(wire({ tool_choice: { type: 'any' } }), undefined, 'stream');
|
|
232
|
+
expect(ka.getStatus().length).toBe(0);
|
|
233
|
+
expect(events.some((e) => e.type === 'skipped')).toBe(true);
|
|
234
|
+
ka.stop();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('is inert when disabled', async () => {
|
|
238
|
+
const send = vi.fn().mockResolvedValue(hit);
|
|
239
|
+
const ka = new CacheKeepalive(send as never, { enabled: false });
|
|
240
|
+
ka.record(wire(), undefined, 'stream');
|
|
241
|
+
await vi.advanceTimersByTimeAsync(6 * 60 * 60_000);
|
|
242
|
+
expect(send).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -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
|
+
}
|