@animalabs/membrane 0.5.80 → 0.5.82

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 (172) hide show
  1. package/dist/cache-wire-receipt.d.ts +13 -0
  2. package/dist/cache-wire-receipt.d.ts.map +1 -0
  3. package/dist/cache-wire-receipt.js +108 -0
  4. package/dist/cache-wire-receipt.js.map +1 -0
  5. package/dist/context/index.d.ts +1 -1
  6. package/dist/context/index.d.ts.map +1 -1
  7. package/dist/context/index.js +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/context/process.d.ts +3 -1
  10. package/dist/context/process.d.ts.map +1 -1
  11. package/dist/context/process.js +230 -49
  12. package/dist/context/process.js.map +1 -1
  13. package/dist/context/types.d.ts +30 -0
  14. package/dist/context/types.d.ts.map +1 -1
  15. package/dist/context/types.js +16 -0
  16. package/dist/context/types.js.map +1 -1
  17. package/dist/floating-cache-marker.test.js +21 -0
  18. package/dist/floating-cache-marker.test.js.map +1 -1
  19. package/dist/formatters/anthropic-xml.d.ts +2 -0
  20. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  21. package/dist/formatters/anthropic-xml.js +64 -23
  22. package/dist/formatters/anthropic-xml.js.map +1 -1
  23. package/dist/formatters/native.d.ts.map +1 -1
  24. package/dist/formatters/native.js +16 -3
  25. package/dist/formatters/native.js.map +1 -1
  26. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  27. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  28. package/dist/formatters/normalize-tool-pairs.js +45 -0
  29. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  30. package/dist/formatters/types.d.ts +31 -0
  31. package/dist/formatters/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/membrane.d.ts +137 -8
  37. package/dist/membrane.d.ts.map +1 -1
  38. package/dist/membrane.js +823 -310
  39. package/dist/membrane.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts +37 -0
  41. package/dist/providers/anthropic.d.ts.map +1 -1
  42. package/dist/providers/anthropic.js +142 -25
  43. package/dist/providers/anthropic.js.map +1 -1
  44. package/dist/providers/bedrock.d.ts +8 -0
  45. package/dist/providers/bedrock.d.ts.map +1 -1
  46. package/dist/providers/bedrock.js +24 -1
  47. package/dist/providers/bedrock.js.map +1 -1
  48. package/dist/providers/gemini.d.ts +11 -0
  49. package/dist/providers/gemini.d.ts.map +1 -1
  50. package/dist/providers/gemini.js +104 -82
  51. package/dist/providers/gemini.js.map +1 -1
  52. package/dist/providers/index.d.ts +1 -1
  53. package/dist/providers/index.d.ts.map +1 -1
  54. package/dist/providers/index.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/content.d.ts +13 -0
  92. package/dist/types/content.d.ts.map +1 -1
  93. package/dist/types/content.js.map +1 -1
  94. package/dist/types/errors.d.ts +15 -0
  95. package/dist/types/errors.d.ts.map +1 -1
  96. package/dist/types/errors.js +26 -0
  97. package/dist/types/errors.js.map +1 -1
  98. package/dist/types/index.d.ts +3 -3
  99. package/dist/types/index.d.ts.map +1 -1
  100. package/dist/types/index.js +1 -1
  101. package/dist/types/index.js.map +1 -1
  102. package/dist/types/provider.d.ts +50 -0
  103. package/dist/types/provider.d.ts.map +1 -1
  104. package/dist/types/request.d.ts +6 -0
  105. package/dist/types/request.d.ts.map +1 -1
  106. package/dist/types/response.d.ts +85 -4
  107. package/dist/types/response.d.ts.map +1 -1
  108. package/dist/types/response.js.map +1 -1
  109. package/dist/types/streaming.d.ts +6 -1
  110. package/dist/types/streaming.d.ts.map +1 -1
  111. package/dist/utils/cache-marker-budget.d.ts +91 -0
  112. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  113. package/dist/utils/cache-marker-budget.js +208 -0
  114. package/dist/utils/cache-marker-budget.js.map +1 -0
  115. package/dist/utils/cost.d.ts +22 -0
  116. package/dist/utils/cost.d.ts.map +1 -1
  117. package/dist/utils/cost.js +55 -0
  118. package/dist/utils/cost.js.map +1 -1
  119. package/dist/utils/thinking-carriers.d.ts +80 -0
  120. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  121. package/dist/utils/thinking-carriers.js +115 -0
  122. package/dist/utils/thinking-carriers.js.map +1 -0
  123. package/dist/utils/tool-parser.d.ts +24 -0
  124. package/dist/utils/tool-parser.d.ts.map +1 -1
  125. package/dist/utils/tool-parser.js +367 -161
  126. package/dist/utils/tool-parser.js.map +1 -1
  127. package/dist/utils/usage.d.ts +71 -0
  128. package/dist/utils/usage.d.ts.map +1 -0
  129. package/dist/utils/usage.js +161 -0
  130. package/dist/utils/usage.js.map +1 -0
  131. package/dist/yielding-stream.d.ts +8 -0
  132. package/dist/yielding-stream.d.ts.map +1 -1
  133. package/dist/yielding-stream.js +49 -3
  134. package/dist/yielding-stream.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/cache-wire-receipt.ts +125 -0
  137. package/src/context/index.ts +1 -0
  138. package/src/context/process.ts +305 -59
  139. package/src/context/types.ts +38 -0
  140. package/src/floating-cache-marker.test.ts +24 -0
  141. package/src/formatters/anthropic-xml.ts +66 -23
  142. package/src/formatters/native.ts +23 -2
  143. package/src/formatters/normalize-tool-pairs.ts +49 -0
  144. package/src/formatters/types.ts +35 -0
  145. package/src/index.ts +6 -0
  146. package/src/membrane.ts +974 -313
  147. package/src/providers/anthropic.ts +173 -19
  148. package/src/providers/bedrock.ts +25 -1
  149. package/src/providers/gemini.ts +125 -87
  150. package/src/providers/index.ts +1 -0
  151. package/src/providers/mock.ts +3 -0
  152. package/src/providers/openai-compatible.ts +102 -45
  153. package/src/providers/openai-completions.ts +112 -46
  154. package/src/providers/openai-responses-api.ts +45 -15
  155. package/src/providers/openai-responses.ts +8 -1
  156. package/src/providers/openai.ts +95 -42
  157. package/src/providers/openrouter.ts +125 -63
  158. package/src/providers/utils.ts +227 -6
  159. package/src/registry/default-pricing.ts +165 -28
  160. package/src/types/content.ts +14 -0
  161. package/src/types/errors.ts +28 -0
  162. package/src/types/index.ts +6 -0
  163. package/src/types/provider.ts +55 -0
  164. package/src/types/request.ts +7 -0
  165. package/src/types/response.ts +109 -8
  166. package/src/types/streaming.ts +8 -2
  167. package/src/utils/cache-marker-budget.ts +237 -0
  168. package/src/utils/cost.ts +64 -0
  169. package/src/utils/thinking-carriers.ts +125 -0
  170. package/src/utils/tool-parser.ts +473 -174
  171. package/src/utils/usage.ts +184 -0
  172. package/src/yielding-stream.ts +54 -3
@@ -66,10 +66,36 @@ export interface ContextConfig {
66
66
  preferUserMessages?: boolean;
67
67
  };
68
68
 
69
+ /**
70
+ * Participant name this deployment's assistant speaks as (membrane's own
71
+ * `assistantParticipant`). Used by the `preferUserMessages` adjustment to
72
+ * tell an assistant turn from a user turn. When unset, the legacy name
73
+ * list (`claude`/`assistant`/`bot`/`ai`) is used.
74
+ */
75
+ assistantParticipant?: string;
76
+
69
77
  /** Custom token estimator (default: chars / 4) */
70
78
  tokenEstimator?: (message: NormalizedMessage) => number;
71
79
  }
72
80
 
81
+ /**
82
+ * Thrown by `processContext` when messages do not carry stable identity.
83
+ *
84
+ * The module keys continuity detection, marker stability and the
85
+ * `cachedStartMessageId` fetch anchor off `metadata.sourceId`. Without it
86
+ * every call sees a brand-new conversation, so rolling and caching are
87
+ * silently disabled — the module refuses rather than degrade invisibly.
88
+ */
89
+ export class MembraneContextIdentityError extends Error {
90
+ constructor(
91
+ message: string,
92
+ public readonly messageIndices: readonly number[]
93
+ ) {
94
+ super(message);
95
+ this.name = 'MembraneContextIdentityError';
96
+ }
97
+ }
98
+
73
99
  // ============================================================================
74
100
  // Context State (persisted between calls)
75
101
  // ============================================================================
@@ -155,6 +181,18 @@ export interface ContextInfo {
155
181
 
156
182
  /** Whether hard limit was hit */
157
183
  hardLimitHit: boolean;
184
+
185
+ /**
186
+ * Set when the window still exceeds a limit after truncation. The kept
187
+ * window is floored at one message, so a single oversize message cannot
188
+ * be truncated away — it is reported here instead of shipping an empty
189
+ * `messages` array.
190
+ */
191
+ residualOverflow?: {
192
+ unit: 'characters' | 'tokens' | 'messages';
193
+ limit: number;
194
+ actual: number;
195
+ };
158
196
 
159
197
  /**
160
198
  * First message ID of the cached window.
@@ -151,6 +151,30 @@ describe('floating cache marker', () => {
151
151
  expect(warn).toHaveBeenCalledTimes(1);
152
152
  });
153
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
+
154
178
  it('floats from the residuum left after the tools/system fallback on markerless requests', () => {
155
179
  const pr = build(makeRequest(), turn(2, false), true);
156
180
  const m = markers(pr);
@@ -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 { assertCacheMarkersWithinLimit, 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',
@@ -143,12 +149,17 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
143
149
  thinking,
144
150
  systemPrompt,
145
151
  promptCaching = false,
152
+ cacheMarkers = 'membrane-system',
146
153
  cacheTtl,
147
154
  contextPrefix,
148
155
  prefillUserMessage,
149
156
  hasCacheMarker,
150
157
  } = options;
151
158
 
159
+ // Membrane resolves the mode per request and passes it here; the
160
+ // constructor-time mode is the fallback for direct callers only.
161
+ const toolMode = options.toolMode ?? this.config.toolMode;
162
+
152
163
  // Build cache_control object (with optional TTL for extended caching)
153
164
  const cacheControl: Record<string, unknown> = { type: 'ephemeral' };
154
165
  if (cacheTtl) {
@@ -165,42 +176,66 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
165
176
  // message continues the same turn, so it must not get a fresh label.
166
177
  let lastWasToolResults = false;
167
178
 
168
- // Track cache markers applied
169
- let cacheMarkersApplied = 0;
170
179
 
171
180
  // Calculate tool injection point
172
181
  const totalMessages = messages.length;
173
182
  const toolInjectionIndex = Math.max(0, totalMessages - this.config.toolInjectionPosition);
174
183
  let toolsInjected = false;
175
184
  const hasToolsForConversation =
176
- this.config.toolMode === 'xml' &&
185
+ toolMode === 'xml' &&
177
186
  this.config.toolInjectionMode === 'conversation' &&
178
187
  tools &&
179
188
  tools.length > 0;
180
189
  const toolsText = hasToolsForConversation ? this.formatToolsForInjection(tools!) : '';
181
190
 
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
- }
191
+ // Build system content. A caller-supplied system ARRAY keeps its block
192
+ // structure and its per-block cache_control: `request.system` explicitly
193
+ // accepts caller-marked blocks, and flattening them into one text block
194
+ // discarded every marker the caller placed (three in, one out) — the
195
+ // caller's stable prefixes then re-paid full input price forever.
196
+ const callerSystemBlocks = Array.isArray(systemPrompt)
197
+ ? systemPrompt
198
+ .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
199
+ .map((b) => {
200
+ const callerMarker = (b as unknown as { cache_control?: unknown }).cache_control;
201
+ const block: Record<string, unknown> = { type: 'text', text: b.text };
202
+ if (callerMarker) block.cache_control = callerMarker;
203
+ return block;
204
+ })
205
+ : undefined;
206
+ const systemBlocks = callerSystemBlocks?.length ? callerSystemBlocks : undefined;
207
+
208
+ let systemText = typeof systemPrompt === 'string'
209
+ ? systemPrompt
210
+ : (systemBlocks?.map((b) => b.text as string).join('\n') ?? '');
190
211
 
191
212
  // Inject tools into system if configured
192
- if (this.config.toolMode === 'xml' && this.config.toolInjectionMode === 'system' && tools?.length) {
213
+ if (toolMode === 'xml' && this.config.toolInjectionMode === 'system' && tools?.length) {
193
214
  const toolsXml = this.formatToolDefinitionsXml(tools);
194
215
  systemText = this.injectToolsIntoSystem(systemText, toolsXml);
216
+ if (systemBlocks) {
217
+ // Append to the LAST block only — appending to the join would
218
+ // collapse the array and take every earlier block's marker with it.
219
+ const tail = systemBlocks[systemBlocks.length - 1]!;
220
+ tail.text = this.injectToolsIntoSystem(tail.text as string, toolsXml);
221
+ }
195
222
  }
196
223
 
197
224
  // Build system content with optional cache control
198
225
  let systemContent: unknown;
199
- if (systemText) {
226
+ if (systemBlocks) {
227
+ // The caller's own markers are authoritative: adding one beside them
228
+ // spends a slot the caller already allocated.
229
+ const callerMarkedAny = systemBlocks.some((b) => b.cache_control);
230
+ if (promptCaching && !callerMarkedAny) {
231
+ const bpIdx = lastCacheableBlockIndex(systemBlocks);
232
+ if (bpIdx >= 0) systemBlocks[bpIdx]!.cache_control = cacheControl;
233
+ }
234
+ systemContent = systemBlocks;
235
+ } else if (systemText) {
200
236
  const systemBlock: Record<string, unknown> = { type: 'text', text: systemText };
201
- if (promptCaching) {
237
+ if (promptCaching && cacheMarkers === 'membrane-system') {
202
238
  systemBlock.cache_control = cacheControl;
203
- cacheMarkersApplied++;
204
239
  }
205
240
  systemContent = [systemBlock];
206
241
  }
@@ -208,9 +243,8 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
208
243
  // Add context prefix as first cached assistant message (for simulacrum seeding)
209
244
  if (contextPrefix) {
210
245
  const prefixBlock: Record<string, unknown> = { type: 'text', text: contextPrefix };
211
- if (promptCaching) {
246
+ if (promptCaching && cacheMarkers === 'membrane-system') {
212
247
  prefixBlock.cache_control = cacheControl;
213
- cacheMarkersApplied++;
214
248
  }
215
249
  providerMessages.push({
216
250
  role: 'assistant',
@@ -276,7 +310,6 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
276
310
  const content = currentConversation.join(joiner);
277
311
  const contentBlock: Record<string, unknown> = { type: 'text', text: content };
278
312
  contentBlock.cache_control = cacheControl;
279
- cacheMarkersApplied++;
280
313
  providerMessages.push({
281
314
  role: 'assistant',
282
315
  content: [contentBlock],
@@ -344,7 +377,6 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
344
377
  const content = currentConversation.join(joiner);
345
378
  const contentBlock: Record<string, unknown> = { type: 'text', text: content };
346
379
  contentBlock.cache_control = cacheControl;
347
- cacheMarkersApplied++;
348
380
  providerMessages.push({
349
381
  role: 'assistant',
350
382
  content: [contentBlock],
@@ -394,9 +426,8 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
394
426
  type: 'text',
395
427
  text: 'The assistant is in CLI simulation mode, and responds to the user\'s CLI commands only with the output of the command.',
396
428
  };
397
- if (promptCaching) {
429
+ if (promptCaching && cacheMarkers === 'membrane-system') {
398
430
  cliSystemBlock.cache_control = cacheControl;
399
- cacheMarkersApplied++;
400
431
  }
401
432
  systemContent = [cliSystemBlock];
402
433
  providerMessages.unshift({
@@ -416,10 +447,22 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
416
447
  const stopSequences = this.buildStopSequences(messages, assistantParticipant, options);
417
448
 
418
449
  // Native tools if configured
419
- const nativeTools = this.config.toolMode === 'native' && tools?.length
450
+ const nativeTools = toolMode === 'native' && tools?.length
420
451
  ? this.convertToNativeTools(tools)
421
452
  : undefined;
422
453
 
454
+ // Budget. Five sites above attach cache_control (system, contextPrefix,
455
+ // hasCacheMarker flush, cacheBreakpoint flush, CLI-simulation system) and
456
+ // multiple cacheBreakpoints are documented input, so a prefill turn with
457
+ // three marked messages reaches five markers — one over Anthropic's hard
458
+ // limit, which rejects the request outright. Clamping here, once, on the
459
+ // finished artifacts is the only count that can see all five sites; the
460
+ // reported tally is that same recount, so it can never drift from the wire.
461
+ const cacheSurfaces = { messages: providerMessages, system: systemContent, tools: nativeTools };
462
+ const budget = cacheMarkers === 'cm-owned'
463
+ ? { total: assertCacheMarkersWithinLimit(cacheSurfaces, 'anthropic-xml') }
464
+ : clampCacheMarkers(cacheSurfaces, 'anthropic-xml');
465
+
423
466
  return {
424
467
  messages: providerMessages,
425
468
  systemContent,
@@ -428,7 +471,7 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
428
471
  : undefined,
429
472
  stopSequences,
430
473
  nativeTools,
431
- cacheMarkersApplied,
474
+ cacheMarkersApplied: budget.total,
432
475
  };
433
476
  }
434
477
 
@@ -30,6 +30,7 @@ import type {
30
30
  } from './types.js';
31
31
  import { normalizeToolPairs, mergeConsecutiveRoles } from './normalize-tool-pairs.js';
32
32
  import { isAcceptedImageMediaType, strippedImagePlaceholder } from '../utils/image-media.js';
33
+ import { assertCacheMarkersWithinLimit } from '../utils/cache-marker-budget.js';
33
34
 
34
35
  /** Index of the last content block that can carry cache_control. Anthropic
35
36
  * rejects cache_control on thinking / redacted_thinking blocks, so a cache
@@ -184,6 +185,7 @@ export class NativeFormatter implements PrefillFormatter {
184
185
  tools,
185
186
  systemPrompt,
186
187
  promptCaching = false,
188
+ cacheMarkers = 'membrane-system',
187
189
  cacheTtl,
188
190
  hasCacheMarker,
189
191
  contextPrefix,
@@ -209,7 +211,7 @@ export class NativeFormatter implements PrefillFormatter {
209
211
  // Add context prefix as first assistant message (for simulacrum seeding)
210
212
  if (contextPrefix) {
211
213
  const prefixBlock: Record<string, unknown> = { type: 'text', text: contextPrefix };
212
- if (promptCaching && cacheControl) {
214
+ if (promptCaching && cacheControl && cacheMarkers === 'membrane-system') {
213
215
  prefixBlock.cache_control = cacheControl;
214
216
  markedBreakpoints++;
215
217
  }
@@ -248,6 +250,13 @@ export class NativeFormatter implements PrefillFormatter {
248
250
  includeNames: participantMode === 'multiuser' && !isAssistant,
249
251
  });
250
252
 
253
+ if (
254
+ cacheMarkers === 'cm-owned' &&
255
+ content.some((block) => Boolean((block as Record<string, unknown>).cache_control))
256
+ ) {
257
+ throw new Error('cm-owned cache markers reject imported block-level cache_control');
258
+ }
259
+
251
260
  if (content.length === 0) {
252
261
  continue; // Skip empty messages
253
262
  }
@@ -302,7 +311,13 @@ export class NativeFormatter implements PrefillFormatter {
302
311
  // Build system content. Cache the system block only as a fallback — when no
303
312
  // message breakpoint was marked (see note above; otherwise a message
304
313
  // breakpoint already caches tools+system as part of its prefix).
305
- const cacheSystem = cacheControl && markedBreakpoints === 0 ? cacheControl : undefined;
314
+ if (markedBreakpoints > 4) {
315
+ throw new Error(`cache_control limit exceeded: ${markedBreakpoints} markers (maximum 4)`);
316
+ }
317
+ const cacheSystem =
318
+ cacheMarkers === 'membrane-system' && cacheControl && markedBreakpoints === 0
319
+ ? cacheControl
320
+ : undefined;
306
321
  let systemContent: unknown;
307
322
  if (typeof systemPrompt === 'string') {
308
323
  if (cacheSystem) {
@@ -327,6 +342,12 @@ export class NativeFormatter implements PrefillFormatter {
327
342
 
328
343
  // Native tools
329
344
  const nativeTools = tools?.length ? this.convertToNativeTools(tools) : undefined;
345
+ if (cacheMarkers === 'cm-owned') {
346
+ assertCacheMarkersWithinLimit(
347
+ { messages: mergedMessages, system: systemContent, tools: nativeTools },
348
+ 'native'
349
+ );
350
+ }
330
351
 
331
352
  return {
332
353
  messages: mergedMessages,
@@ -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
 
@@ -58,6 +68,9 @@ export interface BuildOptions {
58
68
  /** Enable prompt caching (Anthropic-specific) */
59
69
  promptCaching?: boolean;
60
70
 
71
+ /** See NormalizedRequest.cacheMarkers. */
72
+ cacheMarkers?: 'membrane-system' | 'cm-owned';
73
+
61
74
  /** Cache TTL for Anthropic prompt caching - '5m' (default) or '1h' for extended */
62
75
  cacheTtl?: '5m' | '1h';
63
76
 
@@ -163,6 +176,18 @@ export interface BuildResult {
163
176
  /** Number of cache control markers applied (for Anthropic prompt caching) */
164
177
  cacheMarkersApplied?: number;
165
178
 
179
+ /**
180
+ * Offset into the turn's accumulated assistant text at which the CURRENT
181
+ * last message of `messages` begins. Zero (or absent) for an ordinary
182
+ * build: the whole accumulated document is the trailing assistant prefill.
183
+ *
184
+ * A split-turn image injection persists its three messages here and moves
185
+ * this watermark to the image seam, so later continuations replace only
186
+ * the trailing assistant message and never re-flatten the pre-image text
187
+ * over the user turn that carries the image.
188
+ */
189
+ accumulatedBaseOffset?: number;
190
+
166
191
  /**
167
192
  * `false` only when the tool-pair normalizer detected a trailing
168
193
  * unmatched tool_use whose id is in `pendingToolCallIds` — i.e. the
@@ -258,6 +283,16 @@ export interface PrefillFormatter {
258
283
  /** Whether this formatter uses prefill (vs native pass-through) */
259
284
  readonly usesPrefill: boolean;
260
285
 
286
+ /**
287
+ * The tool mode this formatter instance was EXPLICITLY constructed with, if
288
+ * any. Read by `Membrane.resolveToolMode` as the fallback under an explicit
289
+ * `request.toolMode`: a formatter that can build either shape carries its
290
+ * caller's configured choice here so resolution honors it instead of
291
+ * re-deriving one from the formatter's name. Left undefined by formatters
292
+ * that build exactly one shape.
293
+ */
294
+ readonly configuredToolMode?: 'xml' | 'native';
295
+
261
296
  // ==========================================================================
262
297
  // REQUEST BUILDING
263
298
  // ==========================================================================
package/src/index.ts CHANGED
@@ -37,3 +37,9 @@ export type {
37
37
  KeepaliveLane,
38
38
  KeepaliveSend,
39
39
  } from './cache-keepalive.js';
40
+
41
+ export {
42
+ computeCacheWireReceipt,
43
+ type CacheWireReceipt,
44
+ type CacheWireMarkerReceipt,
45
+ } from './cache-wire-receipt.js';