@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,285 @@
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('warns again after the rate-limit interval instead of latching for the process lifetime', () => {
155
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
156
+ const clock = vi.spyOn(Date, 'now');
157
+ clock.mockReturnValue(1_000_000);
158
+
159
+ const messages = turn(3);
160
+ for (const msg of messages) {
161
+ if ((msg.content[0] as any).type === 'tool_result') (msg as any).cacheBreakpoint = true;
162
+ }
163
+ const membrane = new Membrane({ name: 'anthropic' } as any);
164
+
165
+ build(makeRequest(), messages, true, membrane);
166
+ build(makeRequest(), messages, true, membrane);
167
+ expect(warn).toHaveBeenCalledTimes(1);
168
+
169
+ // A long-lived Membrane must not go permanently quiet about an
170
+ // over-budget wire: past the interval the condition reports again, and
171
+ // says how many occurrences it swallowed meanwhile.
172
+ clock.mockReturnValue(1_000_000 + 61_000);
173
+ build(makeRequest(), messages, true, membrane);
174
+ expect(warn).toHaveBeenCalledTimes(2);
175
+ expect(String(warn.mock.calls[1]![0])).toContain('1 further occurrences suppressed');
176
+ });
177
+
178
+ it('floats from the residuum left after the tools/system fallback on markerless requests', () => {
179
+ const pr = build(makeRequest(), turn(2, false), true);
180
+ const m = markers(pr);
181
+ // Fallback spent 2 (tools + system) → residuum 2 → newest + previous endpoint.
182
+ expect(m.onTools).toBe(true);
183
+ expect(m.onSystem).toBe(true);
184
+ expect(m.messages).toEqual([[last(pr) - 2, 0], [last(pr), 0]]);
185
+ });
186
+
187
+ it('steps back off a trailing thinking block', () => {
188
+ const messages = turn(1);
189
+ messages.push({
190
+ participant: 'Claude',
191
+ content: [text('partial'), { type: 'thinking' as const, thinking: 'hmm', signature: 'sig' }],
192
+ } as NormalizedMessage);
193
+ const pr = build(makeRequest(), messages, true);
194
+ const m = markers(pr);
195
+ // Marker lands on the text block, not the thinking block.
196
+ const finalMarks = m.messages.filter(([mi]) => mi === last(pr));
197
+ expect(finalMarks).toHaveLength(1);
198
+ const [, bi] = finalMarks[0]!;
199
+ expect((pr.messages[last(pr)].content[bi] as any).type).toBe('text');
200
+ });
201
+
202
+ it('stands down entirely when the normalizer synthesized a [pending] tool_result', () => {
203
+ // Trailing orphan tool_use → normalizer synthesizes its [pending]
204
+ // result, whose bytes change when the real result lands.
205
+ const messages = [user('go', true), assistantToolCall('t1')];
206
+ const pr = build(makeRequest(), messages, true);
207
+ // Only the kickoff marker; nothing floated at or past the synthetic.
208
+ expect(markers(pr).messages).toEqual([[0, 0]]);
209
+ });
210
+
211
+ it('respects request-level opt-out', () => {
212
+ const pr = build(makeRequest({ floatingCacheMarker: false }), turn(1), true);
213
+ expect(markers(pr).messages).toEqual([[0, 0]]);
214
+ });
215
+
216
+ it('emits no cache_control at all when promptCaching is off', () => {
217
+ const pr = build(makeRequest({ promptCaching: false }), turn(1), true);
218
+ const m = markers(pr);
219
+ expect(m.messages).toEqual([]);
220
+ expect(m.onTools).toBe(false);
221
+ expect(m.onSystem).toBe(false);
222
+ });
223
+
224
+ it('counts pre-marked system blocks against the residual budget (never exceeds 4 on the wire)', () => {
225
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
226
+ // System is a block array with one block already carrying cache_control —
227
+ // invisible to the running message tally, but a real wire marker.
228
+ const premarkedSystem = [
229
+ { type: 'text', text: 'You are a test agent.', cache_control: { type: 'ephemeral' } },
230
+ { type: 'text', text: 'Addendum.' },
231
+ ] as any;
232
+ // 3 message markers + pre-marked system = 4 on the wire: no room to float.
233
+ const full = turn(2);
234
+ for (const msg of full) {
235
+ if ((msg.content[0] as any).type === 'tool_result') (msg as any).cacheBreakpoint = true;
236
+ }
237
+ const prFull = build(makeRequest({ system: premarkedSystem }), full, true);
238
+ expect(totalMarkers(prFull)).toBe(4);
239
+ expect(warn).toHaveBeenCalledTimes(1);
240
+ // 2 message markers + pre-marked system = 3: exactly one slot left — the
241
+ // float takes the newest message and stops.
242
+ const partial = turn(2);
243
+ (partial[2] as any).cacheBreakpoint = true; // first round's results envelope
244
+ const prPartial = build(makeRequest({ system: premarkedSystem }), partial, true);
245
+ expect(totalMarkers(prPartial)).toBe(4);
246
+ expect(markers(prPartial).messages).toContainEqual([last(prPartial), 0]);
247
+ });
248
+
249
+ it('counts an overlapping block-level + message-level marker once (one physical marker)', () => {
250
+ // A stale block-level cache_control on the very block the message's
251
+ // cacheBreakpoint lands on is ONE wire marker, not two. Two such
252
+ // messages must leave residuum 2, not 0.
253
+ const staleMarked = (t: string): NormalizedMessage => ({
254
+ participant: 'User',
255
+ content: [{ type: 'text', text: t, cache_control: { type: 'ephemeral' } } as any],
256
+ cacheBreakpoint: true,
257
+ });
258
+ // Results envelope whose message-level breakpoint lands on a text block
259
+ // that already carries stale cache_control — tool pairing stays intact.
260
+ const overlapResults = (id: string): NormalizedMessage => ({
261
+ participant: 'User',
262
+ content: [
263
+ { type: 'tool_result' as const, toolUseId: id, content: 'ok' },
264
+ { type: 'text', text: 'operator note', cache_control: { type: 'ephemeral' } } as any,
265
+ ],
266
+ cacheBreakpoint: true,
267
+ });
268
+ const messages = [staleMarked('do the thing'),
269
+ assistantToolCall('t1'), overlapResults('t1'),
270
+ assistantToolCall('t2'), toolResults('t2')];
271
+ // 2 physical wire markers (the old tally saw 4 and withheld everything).
272
+ const pr = build(makeRequest(), messages, true);
273
+ const m = markers(pr);
274
+ // Float not withheld: newest message marked; previous endpoint already
275
+ // carries its own marker (dedupe, no slot spent).
276
+ expect(m.messages).toContainEqual([last(pr), 0]);
277
+ expect(totalMarkers(pr)).toBeLessThanOrEqual(4);
278
+ });
279
+
280
+ it('floated markers carry the request cacheTtl', () => {
281
+ const pr = build(makeRequest(), turn(1), true);
282
+ const [mi, bi] = markers(pr).messages.find(([i]) => i === last(pr))!;
283
+ expect((pr.messages[mi].content[bi] as any).cache_control).toEqual({ type: 'ephemeral', ttl: '1h' });
284
+ });
285
+ });
@@ -34,6 +34,8 @@ import {
34
34
  type ToolDefinitionForPrompt,
35
35
  } from '../utils/tool-parser.js';
36
36
  import { IncrementalXmlParser } from '../utils/stream-parser.js';
37
+ import { clampCacheMarkers } from '../utils/cache-marker-budget.js';
38
+ import { lastCacheableBlockIndex } from './native.js';
37
39
  import { isAcceptedImageMediaType, strippedImagePlaceholder } from '../utils/image-media.js';
38
40
 
39
41
  // ============================================================================
@@ -118,9 +120,13 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
118
120
  readonly name = 'anthropic-xml';
119
121
  readonly usesPrefill = true;
120
122
 
123
+ /** See PrefillFormatter.configuredToolMode — undefined when the caller left the mode to Membrane. */
124
+ readonly configuredToolMode: 'xml' | 'native' | undefined;
125
+
121
126
  private config: Required<AnthropicXmlFormatterConfig>;
122
127
 
123
128
  constructor(config: AnthropicXmlFormatterConfig = {}) {
129
+ this.configuredToolMode = config.toolMode;
124
130
  this.config = {
125
131
  toolMode: config.toolMode ?? 'xml',
126
132
  toolInjectionMode: config.toolInjectionMode ?? 'conversation',
@@ -149,6 +155,10 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
149
155
  hasCacheMarker,
150
156
  } = options;
151
157
 
158
+ // Membrane resolves the mode per request and passes it here; the
159
+ // constructor-time mode is the fallback for direct callers only.
160
+ const toolMode = options.toolMode ?? this.config.toolMode;
161
+
152
162
  // Build cache_control object (with optional TTL for extended caching)
153
163
  const cacheControl: Record<string, unknown> = { type: 'ephemeral' };
154
164
  if (cacheTtl) {
@@ -165,42 +175,66 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
165
175
  // message continues the same turn, so it must not get a fresh label.
166
176
  let lastWasToolResults = false;
167
177
 
168
- // Track cache markers applied
169
- let cacheMarkersApplied = 0;
170
178
 
171
179
  // Calculate tool injection point
172
180
  const totalMessages = messages.length;
173
181
  const toolInjectionIndex = Math.max(0, totalMessages - this.config.toolInjectionPosition);
174
182
  let toolsInjected = false;
175
183
  const hasToolsForConversation =
176
- this.config.toolMode === 'xml' &&
184
+ toolMode === 'xml' &&
177
185
  this.config.toolInjectionMode === 'conversation' &&
178
186
  tools &&
179
187
  tools.length > 0;
180
188
  const toolsText = hasToolsForConversation ? this.formatToolsForInjection(tools!) : '';
181
189
 
182
- // Build system content
183
- let systemText = typeof systemPrompt === 'string' ? systemPrompt : '';
184
- if (Array.isArray(systemPrompt)) {
185
- systemText = systemPrompt
186
- .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
187
- .map(b => b.text)
188
- .join('\n');
189
- }
190
+ // Build system content. A caller-supplied system ARRAY keeps its block
191
+ // structure and its per-block cache_control: `request.system` explicitly
192
+ // accepts caller-marked blocks, and flattening them into one text block
193
+ // discarded every marker the caller placed (three in, one out) — the
194
+ // caller's stable prefixes then re-paid full input price forever.
195
+ const callerSystemBlocks = Array.isArray(systemPrompt)
196
+ ? systemPrompt
197
+ .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
198
+ .map((b) => {
199
+ const callerMarker = (b as unknown as { cache_control?: unknown }).cache_control;
200
+ const block: Record<string, unknown> = { type: 'text', text: b.text };
201
+ if (callerMarker) block.cache_control = callerMarker;
202
+ return block;
203
+ })
204
+ : undefined;
205
+ const systemBlocks = callerSystemBlocks?.length ? callerSystemBlocks : undefined;
206
+
207
+ let systemText = typeof systemPrompt === 'string'
208
+ ? systemPrompt
209
+ : (systemBlocks?.map((b) => b.text as string).join('\n') ?? '');
190
210
 
191
211
  // Inject tools into system if configured
192
- if (this.config.toolMode === 'xml' && this.config.toolInjectionMode === 'system' && tools?.length) {
212
+ if (toolMode === 'xml' && this.config.toolInjectionMode === 'system' && tools?.length) {
193
213
  const toolsXml = this.formatToolDefinitionsXml(tools);
194
214
  systemText = this.injectToolsIntoSystem(systemText, toolsXml);
215
+ if (systemBlocks) {
216
+ // Append to the LAST block only — appending to the join would
217
+ // collapse the array and take every earlier block's marker with it.
218
+ const tail = systemBlocks[systemBlocks.length - 1]!;
219
+ tail.text = this.injectToolsIntoSystem(tail.text as string, toolsXml);
220
+ }
195
221
  }
196
222
 
197
223
  // Build system content with optional cache control
198
224
  let systemContent: unknown;
199
- if (systemText) {
225
+ if (systemBlocks) {
226
+ // The caller's own markers are authoritative: adding one beside them
227
+ // spends a slot the caller already allocated.
228
+ const callerMarkedAny = systemBlocks.some((b) => b.cache_control);
229
+ if (promptCaching && !callerMarkedAny) {
230
+ const bpIdx = lastCacheableBlockIndex(systemBlocks);
231
+ if (bpIdx >= 0) systemBlocks[bpIdx]!.cache_control = cacheControl;
232
+ }
233
+ systemContent = systemBlocks;
234
+ } else if (systemText) {
200
235
  const systemBlock: Record<string, unknown> = { type: 'text', text: systemText };
201
236
  if (promptCaching) {
202
237
  systemBlock.cache_control = cacheControl;
203
- cacheMarkersApplied++;
204
238
  }
205
239
  systemContent = [systemBlock];
206
240
  }
@@ -210,7 +244,6 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
210
244
  const prefixBlock: Record<string, unknown> = { type: 'text', text: contextPrefix };
211
245
  if (promptCaching) {
212
246
  prefixBlock.cache_control = cacheControl;
213
- cacheMarkersApplied++;
214
247
  }
215
248
  providerMessages.push({
216
249
  role: 'assistant',
@@ -276,7 +309,6 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
276
309
  const content = currentConversation.join(joiner);
277
310
  const contentBlock: Record<string, unknown> = { type: 'text', text: content };
278
311
  contentBlock.cache_control = cacheControl;
279
- cacheMarkersApplied++;
280
312
  providerMessages.push({
281
313
  role: 'assistant',
282
314
  content: [contentBlock],
@@ -344,7 +376,6 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
344
376
  const content = currentConversation.join(joiner);
345
377
  const contentBlock: Record<string, unknown> = { type: 'text', text: content };
346
378
  contentBlock.cache_control = cacheControl;
347
- cacheMarkersApplied++;
348
379
  providerMessages.push({
349
380
  role: 'assistant',
350
381
  content: [contentBlock],
@@ -396,7 +427,6 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
396
427
  };
397
428
  if (promptCaching) {
398
429
  cliSystemBlock.cache_control = cacheControl;
399
- cacheMarkersApplied++;
400
430
  }
401
431
  systemContent = [cliSystemBlock];
402
432
  providerMessages.unshift({
@@ -416,10 +446,22 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
416
446
  const stopSequences = this.buildStopSequences(messages, assistantParticipant, options);
417
447
 
418
448
  // Native tools if configured
419
- const nativeTools = this.config.toolMode === 'native' && tools?.length
449
+ const nativeTools = toolMode === 'native' && tools?.length
420
450
  ? this.convertToNativeTools(tools)
421
451
  : undefined;
422
452
 
453
+ // Budget. Five sites above attach cache_control (system, contextPrefix,
454
+ // hasCacheMarker flush, cacheBreakpoint flush, CLI-simulation system) and
455
+ // multiple cacheBreakpoints are documented input, so a prefill turn with
456
+ // three marked messages reaches five markers — one over Anthropic's hard
457
+ // limit, which rejects the request outright. Clamping here, once, on the
458
+ // finished artifacts is the only count that can see all five sites; the
459
+ // reported tally is that same recount, so it can never drift from the wire.
460
+ const budget = clampCacheMarkers(
461
+ { messages: providerMessages, system: systemContent, tools: nativeTools },
462
+ 'anthropic-xml'
463
+ );
464
+
423
465
  return {
424
466
  messages: providerMessages,
425
467
  systemContent,
@@ -428,7 +470,7 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
428
470
  : undefined,
429
471
  stopSequences,
430
472
  nativeTools,
431
- cacheMarkersApplied,
473
+ cacheMarkersApplied: budget.total,
432
474
  };
433
475
  }
434
476
 
@@ -43,6 +43,55 @@ import type { NormalizeEvent } from './types.js';
43
43
  */
44
44
  export type ProviderBlock = Record<string, unknown> & { type: string };
45
45
 
46
+ /**
47
+ * Does this repair REWRITE prefix bytes, or only re-shape envelopes?
48
+ *
49
+ * The two `true` entries are placeholders that get rewritten again the moment
50
+ * the real pairing arrives — the synthetic `[pending]` result when its
51
+ * tool_result lands, the textified orphan when its tool_use does — so a cache
52
+ * breakpoint placed at or past one caches a prefix that is about to change,
53
+ * poisoning every subsequent read.
54
+ *
55
+ * EVERY kind is listed, and the type is a `Record` over the whole union
56
+ * precisely so the compiler forces that: a new `NormalizeEvent` kind is a
57
+ * missing-property error here until someone decides which side it falls on.
58
+ * The previous shape (`[...] satisfies Array<NormalizeEvent['kind']>`) only
59
+ * checked that the listed kinds were REAL, and a `satisfies` on an array
60
+ * accepts any subset — so a new prefix-rewriting repair could be added to the
61
+ * union, never listed, and silently escape the cache gate below while the
62
+ * comment claimed a compile error that did not exist.
63
+ *
64
+ * NOTE FOR COMPOSE: the `stray_tool_result_textified` kind landing from the
65
+ * stray-orphan lane belongs here as `true` — it rewrites prefix bytes for the
66
+ * same reason `orphan_tool_result_textified` does.
67
+ */
68
+ const NORMALIZE_EVENT_REWRITES_PREFIX: Record<NormalizeEvent['kind'], boolean> = {
69
+ block_re_roled: false,
70
+ tool_result_hoisted: false,
71
+ interloper_deferred: false,
72
+ synthetic_pending_result: true,
73
+ orphan_tool_result_textified: true,
74
+ pending_in_flight: false,
75
+ cache_suppressed_for_synthetic: false,
76
+ leading_user_synthesized: false,
77
+ };
78
+
79
+ function deriveKindsThatRewritePrefix(): ReadonlySet<NormalizeEvent['kind']> {
80
+ const kinds = Object.entries(NORMALIZE_EVENT_REWRITES_PREFIX)
81
+ .filter(([, rewritesPrefix]) => rewritesPrefix)
82
+ .map(([kind]) => kind as NormalizeEvent['kind']);
83
+ return new Set(kinds);
84
+ }
85
+
86
+ /**
87
+ * The repairs that rewrite prefix bytes, derived from the exhaustive
88
+ * classification above. Consumers gate cache placement on this SET rather
89
+ * than on a single kind, so a normalizer that grows another prefix-rewriting
90
+ * repair is covered at every cache site by classifying it once.
91
+ */
92
+ export const PREFIX_REWRITING_NORMALIZE_EVENT_KINDS: ReadonlySet<NormalizeEvent['kind']> =
93
+ deriveKindsThatRewritePrefix();
94
+
46
95
  export interface NormalizeOptions {
47
96
  /** See `BuildOptions.pendingToolCallIds`. */
48
97
  pendingToolCallIds?: ReadonlySet<string>;
@@ -49,6 +49,16 @@ export interface BuildOptions {
49
49
  /** Tool definitions to include */
50
50
  tools?: ToolDefinition[];
51
51
 
52
+ /**
53
+ * The tool mode Membrane RESOLVED for this request (see
54
+ * `Membrane.resolveToolMode`), which already accounts for `request.toolMode`,
55
+ * the formatter's own configured mode, and provider/formatter derivation.
56
+ * A formatter that supports both shapes must build for THIS mode; its
57
+ * constructor-time mode is only the fallback for direct `buildMessages`
58
+ * callers that resolve nothing.
59
+ */
60
+ toolMode?: 'xml' | 'native';
61
+
52
62
  /** Whether thinking is enabled */
53
63
  thinking?: { enabled: boolean; budgetTokens?: number };
54
64
 
@@ -163,6 +173,18 @@ export interface BuildResult {
163
173
  /** Number of cache control markers applied (for Anthropic prompt caching) */
164
174
  cacheMarkersApplied?: number;
165
175
 
176
+ /**
177
+ * Offset into the turn's accumulated assistant text at which the CURRENT
178
+ * last message of `messages` begins. Zero (or absent) for an ordinary
179
+ * build: the whole accumulated document is the trailing assistant prefill.
180
+ *
181
+ * A split-turn image injection persists its three messages here and moves
182
+ * this watermark to the image seam, so later continuations replace only
183
+ * the trailing assistant message and never re-flatten the pre-image text
184
+ * over the user turn that carries the image.
185
+ */
186
+ accumulatedBaseOffset?: number;
187
+
166
188
  /**
167
189
  * `false` only when the tool-pair normalizer detected a trailing
168
190
  * unmatched tool_use whose id is in `pendingToolCallIds` — i.e. the
@@ -258,6 +280,16 @@ export interface PrefillFormatter {
258
280
  /** Whether this formatter uses prefill (vs native pass-through) */
259
281
  readonly usesPrefill: boolean;
260
282
 
283
+ /**
284
+ * The tool mode this formatter instance was EXPLICITLY constructed with, if
285
+ * any. Read by `Membrane.resolveToolMode` as the fallback under an explicit
286
+ * `request.toolMode`: a formatter that can build either shape carries its
287
+ * caller's configured choice here so resolution honors it instead of
288
+ * re-deriving one from the formatter's name. Left undefined by formatters
289
+ * that build exactly one shape.
290
+ */
291
+ readonly configuredToolMode?: 'xml' | 'native';
292
+
261
293
  // ==========================================================================
262
294
  // REQUEST BUILDING
263
295
  // ==========================================================================
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';