@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.
Files changed (175) hide show
  1. package/dist/cache-keepalive.d.ts +115 -0
  2. package/dist/cache-keepalive.d.ts.map +1 -0
  3. package/dist/cache-keepalive.js +0 -0
  4. package/dist/cache-keepalive.js.map +1 -0
  5. package/dist/cache-keepalive.test.d.ts +2 -0
  6. package/dist/cache-keepalive.test.d.ts.map +1 -0
  7. package/dist/cache-keepalive.test.js +206 -0
  8. package/dist/cache-keepalive.test.js.map +1 -0
  9. package/dist/context/index.d.ts +1 -1
  10. package/dist/context/index.d.ts.map +1 -1
  11. package/dist/context/index.js +1 -1
  12. package/dist/context/index.js.map +1 -1
  13. package/dist/context/process.d.ts +3 -1
  14. package/dist/context/process.d.ts.map +1 -1
  15. package/dist/context/process.js +230 -49
  16. package/dist/context/process.js.map +1 -1
  17. package/dist/context/types.d.ts +30 -0
  18. package/dist/context/types.d.ts.map +1 -1
  19. package/dist/context/types.js +16 -0
  20. package/dist/context/types.js.map +1 -1
  21. package/dist/floating-cache-marker.test.d.ts +2 -0
  22. package/dist/floating-cache-marker.test.d.ts.map +1 -0
  23. package/dist/floating-cache-marker.test.js +263 -0
  24. package/dist/floating-cache-marker.test.js.map +1 -0
  25. package/dist/formatters/anthropic-xml.d.ts +2 -0
  26. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  27. package/dist/formatters/anthropic-xml.js +57 -19
  28. package/dist/formatters/anthropic-xml.js.map +1 -1
  29. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  30. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  31. package/dist/formatters/normalize-tool-pairs.js +45 -0
  32. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  33. package/dist/formatters/types.d.ts +29 -0
  34. package/dist/formatters/types.d.ts.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/membrane.d.ts +142 -7
  40. package/dist/membrane.d.ts.map +1 -1
  41. package/dist/membrane.js +881 -270
  42. package/dist/membrane.js.map +1 -1
  43. package/dist/providers/anthropic.d.ts +37 -0
  44. package/dist/providers/anthropic.d.ts.map +1 -1
  45. package/dist/providers/anthropic.js +163 -26
  46. package/dist/providers/anthropic.js.map +1 -1
  47. package/dist/providers/bedrock.d.ts +8 -0
  48. package/dist/providers/bedrock.d.ts.map +1 -1
  49. package/dist/providers/bedrock.js +24 -1
  50. package/dist/providers/bedrock.js.map +1 -1
  51. package/dist/providers/gemini.d.ts +11 -0
  52. package/dist/providers/gemini.d.ts.map +1 -1
  53. package/dist/providers/gemini.js +104 -82
  54. package/dist/providers/gemini.js.map +1 -1
  55. package/dist/providers/mock.d.ts +2 -0
  56. package/dist/providers/mock.d.ts.map +1 -1
  57. package/dist/providers/mock.js +2 -0
  58. package/dist/providers/mock.js.map +1 -1
  59. package/dist/providers/openai-compatible.d.ts +7 -0
  60. package/dist/providers/openai-compatible.d.ts.map +1 -1
  61. package/dist/providers/openai-compatible.js +99 -48
  62. package/dist/providers/openai-compatible.js.map +1 -1
  63. package/dist/providers/openai-completions.d.ts +5 -0
  64. package/dist/providers/openai-completions.d.ts.map +1 -1
  65. package/dist/providers/openai-completions.js +107 -44
  66. package/dist/providers/openai-completions.js.map +1 -1
  67. package/dist/providers/openai-responses-api.d.ts +16 -0
  68. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  69. package/dist/providers/openai-responses-api.js +38 -10
  70. package/dist/providers/openai-responses-api.js.map +1 -1
  71. package/dist/providers/openai-responses.d.ts +2 -0
  72. package/dist/providers/openai-responses.d.ts.map +1 -1
  73. package/dist/providers/openai-responses.js +8 -1
  74. package/dist/providers/openai-responses.js.map +1 -1
  75. package/dist/providers/openai.d.ts +7 -0
  76. package/dist/providers/openai.d.ts.map +1 -1
  77. package/dist/providers/openai.js +92 -44
  78. package/dist/providers/openai.js.map +1 -1
  79. package/dist/providers/openrouter.d.ts +9 -0
  80. package/dist/providers/openrouter.d.ts.map +1 -1
  81. package/dist/providers/openrouter.js +118 -62
  82. package/dist/providers/openrouter.js.map +1 -1
  83. package/dist/providers/utils.d.ts +74 -2
  84. package/dist/providers/utils.d.ts.map +1 -1
  85. package/dist/providers/utils.js +184 -3
  86. package/dist/providers/utils.js.map +1 -1
  87. package/dist/registry/default-pricing.d.ts +22 -0
  88. package/dist/registry/default-pricing.d.ts.map +1 -1
  89. package/dist/registry/default-pricing.js +162 -28
  90. package/dist/registry/default-pricing.js.map +1 -1
  91. package/dist/types/config.d.ts +5 -0
  92. package/dist/types/config.d.ts.map +1 -1
  93. package/dist/types/config.js.map +1 -1
  94. package/dist/types/content.d.ts +13 -0
  95. package/dist/types/content.d.ts.map +1 -1
  96. package/dist/types/content.js.map +1 -1
  97. package/dist/types/errors.d.ts +15 -0
  98. package/dist/types/errors.d.ts.map +1 -1
  99. package/dist/types/errors.js +26 -0
  100. package/dist/types/errors.js.map +1 -1
  101. package/dist/types/index.d.ts +3 -3
  102. package/dist/types/index.d.ts.map +1 -1
  103. package/dist/types/index.js +1 -1
  104. package/dist/types/index.js.map +1 -1
  105. package/dist/types/provider.d.ts +50 -0
  106. package/dist/types/provider.d.ts.map +1 -1
  107. package/dist/types/request.d.ts +13 -0
  108. package/dist/types/request.d.ts.map +1 -1
  109. package/dist/types/response.d.ts +85 -4
  110. package/dist/types/response.d.ts.map +1 -1
  111. package/dist/types/response.js.map +1 -1
  112. package/dist/types/streaming.d.ts +6 -1
  113. package/dist/types/streaming.d.ts.map +1 -1
  114. package/dist/utils/cache-marker-budget.d.ts +82 -0
  115. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  116. package/dist/utils/cache-marker-budget.js +189 -0
  117. package/dist/utils/cache-marker-budget.js.map +1 -0
  118. package/dist/utils/cost.d.ts +22 -0
  119. package/dist/utils/cost.d.ts.map +1 -1
  120. package/dist/utils/cost.js +55 -0
  121. package/dist/utils/cost.js.map +1 -1
  122. package/dist/utils/thinking-carriers.d.ts +80 -0
  123. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  124. package/dist/utils/thinking-carriers.js +115 -0
  125. package/dist/utils/thinking-carriers.js.map +1 -0
  126. package/dist/utils/tool-parser.d.ts +24 -0
  127. package/dist/utils/tool-parser.d.ts.map +1 -1
  128. package/dist/utils/tool-parser.js +367 -161
  129. package/dist/utils/tool-parser.js.map +1 -1
  130. package/dist/utils/usage.d.ts +71 -0
  131. package/dist/utils/usage.d.ts.map +1 -0
  132. package/dist/utils/usage.js +161 -0
  133. package/dist/utils/usage.js.map +1 -0
  134. package/dist/yielding-stream.d.ts +8 -0
  135. package/dist/yielding-stream.d.ts.map +1 -1
  136. package/dist/yielding-stream.js +49 -3
  137. package/dist/yielding-stream.js.map +1 -1
  138. package/package.json +3 -2
  139. package/src/cache-keepalive.test.ts +244 -0
  140. package/src/cache-keepalive.ts +385 -0
  141. package/src/context/index.ts +1 -0
  142. package/src/context/process.ts +305 -59
  143. package/src/context/types.ts +38 -0
  144. package/src/floating-cache-marker.test.ts +285 -0
  145. package/src/formatters/anthropic-xml.ts +62 -20
  146. package/src/formatters/normalize-tool-pairs.ts +49 -0
  147. package/src/formatters/types.ts +32 -0
  148. package/src/index.ts +13 -0
  149. package/src/membrane.ts +1031 -279
  150. package/src/providers/anthropic.ts +205 -20
  151. package/src/providers/bedrock.ts +25 -1
  152. package/src/providers/gemini.ts +125 -87
  153. package/src/providers/mock.ts +3 -0
  154. package/src/providers/openai-compatible.ts +102 -45
  155. package/src/providers/openai-completions.ts +112 -46
  156. package/src/providers/openai-responses-api.ts +45 -15
  157. package/src/providers/openai-responses.ts +8 -1
  158. package/src/providers/openai.ts +95 -42
  159. package/src/providers/openrouter.ts +125 -63
  160. package/src/providers/utils.ts +227 -6
  161. package/src/registry/default-pricing.ts +165 -28
  162. package/src/types/config.ts +6 -0
  163. package/src/types/content.ts +14 -0
  164. package/src/types/errors.ts +28 -0
  165. package/src/types/index.ts +6 -0
  166. package/src/types/provider.ts +55 -0
  167. package/src/types/request.ts +14 -0
  168. package/src/types/response.ts +109 -8
  169. package/src/types/streaming.ts +8 -2
  170. package/src/utils/cache-marker-budget.ts +208 -0
  171. package/src/utils/cost.ts +64 -0
  172. package/src/utils/thinking-carriers.ts +125 -0
  173. package/src/utils/tool-parser.ts +473 -174
  174. package/src/utils/usage.ts +184 -0
  175. 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
+ }
@@ -25,6 +25,7 @@ export {
25
25
  createInitialState,
26
26
  defaultTokenEstimator,
27
27
  DEFAULT_CONTEXT_CONFIG,
28
+ MembraneContextIdentityError,
28
29
  } from './types.js';
29
30
 
30
31
  export type {