@animalabs/membrane 0.5.80 → 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 (151) hide show
  1. package/dist/context/index.d.ts +1 -1
  2. package/dist/context/index.d.ts.map +1 -1
  3. package/dist/context/index.js +1 -1
  4. package/dist/context/index.js.map +1 -1
  5. package/dist/context/process.d.ts +3 -1
  6. package/dist/context/process.d.ts.map +1 -1
  7. package/dist/context/process.js +230 -49
  8. package/dist/context/process.js.map +1 -1
  9. package/dist/context/types.d.ts +30 -0
  10. package/dist/context/types.d.ts.map +1 -1
  11. package/dist/context/types.js +16 -0
  12. package/dist/context/types.js.map +1 -1
  13. package/dist/floating-cache-marker.test.js +21 -0
  14. package/dist/floating-cache-marker.test.js.map +1 -1
  15. package/dist/formatters/anthropic-xml.d.ts +2 -0
  16. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  17. package/dist/formatters/anthropic-xml.js +57 -19
  18. package/dist/formatters/anthropic-xml.js.map +1 -1
  19. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  20. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  21. package/dist/formatters/normalize-tool-pairs.js +45 -0
  22. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  23. package/dist/formatters/types.d.ts +29 -0
  24. package/dist/formatters/types.d.ts.map +1 -1
  25. package/dist/membrane.d.ts +137 -8
  26. package/dist/membrane.d.ts.map +1 -1
  27. package/dist/membrane.js +812 -310
  28. package/dist/membrane.js.map +1 -1
  29. package/dist/providers/anthropic.d.ts +26 -0
  30. package/dist/providers/anthropic.d.ts.map +1 -1
  31. package/dist/providers/anthropic.js +142 -25
  32. package/dist/providers/anthropic.js.map +1 -1
  33. package/dist/providers/bedrock.d.ts +8 -0
  34. package/dist/providers/bedrock.d.ts.map +1 -1
  35. package/dist/providers/bedrock.js +24 -1
  36. package/dist/providers/bedrock.js.map +1 -1
  37. package/dist/providers/gemini.d.ts +11 -0
  38. package/dist/providers/gemini.d.ts.map +1 -1
  39. package/dist/providers/gemini.js +104 -82
  40. package/dist/providers/gemini.js.map +1 -1
  41. package/dist/providers/mock.d.ts +2 -0
  42. package/dist/providers/mock.d.ts.map +1 -1
  43. package/dist/providers/mock.js +2 -0
  44. package/dist/providers/mock.js.map +1 -1
  45. package/dist/providers/openai-compatible.d.ts +7 -0
  46. package/dist/providers/openai-compatible.d.ts.map +1 -1
  47. package/dist/providers/openai-compatible.js +99 -48
  48. package/dist/providers/openai-compatible.js.map +1 -1
  49. package/dist/providers/openai-completions.d.ts +5 -0
  50. package/dist/providers/openai-completions.d.ts.map +1 -1
  51. package/dist/providers/openai-completions.js +107 -44
  52. package/dist/providers/openai-completions.js.map +1 -1
  53. package/dist/providers/openai-responses-api.d.ts +16 -0
  54. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  55. package/dist/providers/openai-responses-api.js +38 -10
  56. package/dist/providers/openai-responses-api.js.map +1 -1
  57. package/dist/providers/openai-responses.d.ts +2 -0
  58. package/dist/providers/openai-responses.d.ts.map +1 -1
  59. package/dist/providers/openai-responses.js +8 -1
  60. package/dist/providers/openai-responses.js.map +1 -1
  61. package/dist/providers/openai.d.ts +7 -0
  62. package/dist/providers/openai.d.ts.map +1 -1
  63. package/dist/providers/openai.js +92 -44
  64. package/dist/providers/openai.js.map +1 -1
  65. package/dist/providers/openrouter.d.ts +9 -0
  66. package/dist/providers/openrouter.d.ts.map +1 -1
  67. package/dist/providers/openrouter.js +118 -62
  68. package/dist/providers/openrouter.js.map +1 -1
  69. package/dist/providers/utils.d.ts +74 -2
  70. package/dist/providers/utils.d.ts.map +1 -1
  71. package/dist/providers/utils.js +184 -3
  72. package/dist/providers/utils.js.map +1 -1
  73. package/dist/registry/default-pricing.d.ts +22 -0
  74. package/dist/registry/default-pricing.d.ts.map +1 -1
  75. package/dist/registry/default-pricing.js +162 -28
  76. package/dist/registry/default-pricing.js.map +1 -1
  77. package/dist/types/content.d.ts +13 -0
  78. package/dist/types/content.d.ts.map +1 -1
  79. package/dist/types/content.js.map +1 -1
  80. package/dist/types/errors.d.ts +15 -0
  81. package/dist/types/errors.d.ts.map +1 -1
  82. package/dist/types/errors.js +26 -0
  83. package/dist/types/errors.js.map +1 -1
  84. package/dist/types/index.d.ts +3 -3
  85. package/dist/types/index.d.ts.map +1 -1
  86. package/dist/types/index.js +1 -1
  87. package/dist/types/index.js.map +1 -1
  88. package/dist/types/provider.d.ts +50 -0
  89. package/dist/types/provider.d.ts.map +1 -1
  90. package/dist/types/response.d.ts +85 -4
  91. package/dist/types/response.d.ts.map +1 -1
  92. package/dist/types/response.js.map +1 -1
  93. package/dist/types/streaming.d.ts +6 -1
  94. package/dist/types/streaming.d.ts.map +1 -1
  95. package/dist/utils/cache-marker-budget.d.ts +82 -0
  96. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  97. package/dist/utils/cache-marker-budget.js +189 -0
  98. package/dist/utils/cache-marker-budget.js.map +1 -0
  99. package/dist/utils/cost.d.ts +22 -0
  100. package/dist/utils/cost.d.ts.map +1 -1
  101. package/dist/utils/cost.js +55 -0
  102. package/dist/utils/cost.js.map +1 -1
  103. package/dist/utils/thinking-carriers.d.ts +80 -0
  104. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  105. package/dist/utils/thinking-carriers.js +115 -0
  106. package/dist/utils/thinking-carriers.js.map +1 -0
  107. package/dist/utils/tool-parser.d.ts +24 -0
  108. package/dist/utils/tool-parser.d.ts.map +1 -1
  109. package/dist/utils/tool-parser.js +367 -161
  110. package/dist/utils/tool-parser.js.map +1 -1
  111. package/dist/utils/usage.d.ts +71 -0
  112. package/dist/utils/usage.d.ts.map +1 -0
  113. package/dist/utils/usage.js +161 -0
  114. package/dist/utils/usage.js.map +1 -0
  115. package/dist/yielding-stream.d.ts +8 -0
  116. package/dist/yielding-stream.d.ts.map +1 -1
  117. package/dist/yielding-stream.js +49 -3
  118. package/dist/yielding-stream.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/context/index.ts +1 -0
  121. package/src/context/process.ts +305 -59
  122. package/src/context/types.ts +38 -0
  123. package/src/floating-cache-marker.test.ts +24 -0
  124. package/src/formatters/anthropic-xml.ts +62 -20
  125. package/src/formatters/normalize-tool-pairs.ts +49 -0
  126. package/src/formatters/types.ts +32 -0
  127. package/src/membrane.ts +963 -313
  128. package/src/providers/anthropic.ts +161 -19
  129. package/src/providers/bedrock.ts +25 -1
  130. package/src/providers/gemini.ts +125 -87
  131. package/src/providers/mock.ts +3 -0
  132. package/src/providers/openai-compatible.ts +102 -45
  133. package/src/providers/openai-completions.ts +112 -46
  134. package/src/providers/openai-responses-api.ts +45 -15
  135. package/src/providers/openai-responses.ts +8 -1
  136. package/src/providers/openai.ts +95 -42
  137. package/src/providers/openrouter.ts +125 -63
  138. package/src/providers/utils.ts +227 -6
  139. package/src/registry/default-pricing.ts +165 -28
  140. package/src/types/content.ts +14 -0
  141. package/src/types/errors.ts +28 -0
  142. package/src/types/index.ts +6 -0
  143. package/src/types/provider.ts +55 -0
  144. package/src/types/response.ts +109 -8
  145. package/src/types/streaming.ts +8 -2
  146. package/src/utils/cache-marker-budget.ts +208 -0
  147. package/src/utils/cost.ts +64 -0
  148. package/src/utils/thinking-carriers.ts +125 -0
  149. package/src/utils/tool-parser.ts +473 -174
  150. package/src/utils/usage.ts +184 -0
  151. package/src/yielding-stream.ts +54 -3
package/src/membrane.ts CHANGED
@@ -10,12 +10,14 @@ import type {
10
10
  AbortedResponse,
11
11
  ContentBlock,
12
12
  ProviderAdapter,
13
+ ProviderResponse,
13
14
  ModelRegistry,
14
15
  MembraneConfig,
15
16
  StreamOptions,
16
17
  CompleteOptions,
17
18
  BasicUsage,
18
19
  DetailedUsage,
20
+ DiscardedAttemptsUsage,
19
21
  StopReason,
20
22
  TimingInfo,
21
23
  CacheInfo,
@@ -23,23 +25,37 @@ import type {
23
25
  ToolResult,
24
26
  ToolContext,
25
27
  RetryConfig,
26
- ToolMode,
27
28
  ToolDefinition,
28
29
  } from './types/index.js';
29
30
  import { lastCacheableBlockIndex } from './formatters/native.js';
31
+ import {
32
+ sameThinkingText,
33
+ findSpanningProviderRun,
34
+ thinkingCarrierKey,
35
+ stripThinkingForPrefill,
36
+ } from './utils/thinking-carriers.js';
37
+ import {
38
+ countWireCacheMarkers,
39
+ clampCacheMarkers,
40
+ ownSystemBlocks,
41
+ MAX_CACHE_BREAKPOINTS,
42
+ } from './utils/cache-marker-budget.js';
30
43
  import {
31
44
  DEFAULT_RETRY_CONFIG,
32
45
  MembraneError,
33
46
  classifyError,
34
47
  isOverloadedError,
48
+ isTimeoutAbortError,
35
49
  isTextContent,
36
50
  isAbortedResponse,
51
+ unsupportedError,
37
52
  } from './types/index.js';
38
53
  import type { BuildResult } from './formatters/types.js';
39
54
  import {
40
55
  parseToolCalls,
41
56
  formatToolResults,
42
57
  parseAccumulatedIntoBlocks,
58
+ endsWithPartialToolBlock,
43
59
  hasImageInToolResults,
44
60
  formatToolResultsForSplitTurn,
45
61
  type ProviderImageBlock,
@@ -54,9 +70,19 @@ import type {
54
70
  } from './types/yielding-stream.js';
55
71
  import type { PrefillFormatter, StreamParser } from './formatters/types.js';
56
72
  import { AnthropicXmlFormatter } from './formatters/anthropic-xml.js';
57
- import { normalizeToolPairs, mergeConsecutiveRoles } from './formatters/normalize-tool-pairs.js';
73
+ import {
74
+ normalizeToolPairs,
75
+ mergeConsecutiveRoles,
76
+ PREFIX_REWRITING_NORMALIZE_EVENT_KINDS,
77
+ } from './formatters/normalize-tool-pairs.js';
58
78
  import { YieldingStreamImpl } from './yielding-stream.js';
59
- import { calculateCost } from './utils/cost.js';
79
+ import { calculateCost, warnUnpricedModel } from './utils/cost.js';
80
+ import {
81
+ TurnUsageAccumulator,
82
+ calculateCacheHitRatio,
83
+ normalizeUsageToCacheExcluded,
84
+ warnUnconvertibleProviderItem,
85
+ } from './utils/usage.js';
60
86
  import {
61
87
  isAcceptedImageMediaType,
62
88
  strippedImagePlaceholder,
@@ -68,6 +94,94 @@ import { getDefaultPricing } from './registry/default-pricing.js';
68
94
  // Membrane Class
69
95
  // ============================================================================
70
96
 
97
+ /**
98
+ * Block-lifecycle tracking shared by the two native-tools streaming paths
99
+ * (`streamWithNativeTools` and `runNativeToolsYielding`).
100
+ *
101
+ * Providers signal blocks through `onContentBlock(index, block)`, but not all
102
+ * of them the same way: the Anthropic and Bedrock adapters fire it twice per
103
+ * index (content_block_start with an empty block, content_block_stop with the
104
+ * finalised one), while the OpenAI Responses adapter fires it ONCE per block,
105
+ * already finalised, after the stream has ended. Treating "second sighting"
106
+ * as the only completion signal therefore left single-callback adapters with
107
+ * `block_start` events that never completed (#63 review). The tracker keeps
108
+ * the paired semantics and adds `flush()`, which the caller runs once the
109
+ * provider stream has returned: every started block that never saw a second
110
+ * callback is completed from the last block payload seen for it.
111
+ */
112
+ class NativeBlockTracker {
113
+ currentType: MembraneBlockType = 'text';
114
+ blockIndex = 0;
115
+ private readonly started = new Map<number, MembraneBlockType>();
116
+ private readonly completed = new Set<number>();
117
+ private readonly lastSeen = new Map<number, unknown>();
118
+
119
+ constructor(private readonly emit: ((event: BlockEvent) => void) | undefined) {}
120
+
121
+ static mapApiBlockType(apiType: string | undefined): MembraneBlockType {
122
+ if (apiType === 'thinking' || apiType === 'redacted_thinking' || apiType === 'reasoning') return 'thinking';
123
+ if (apiType === 'tool_use' || apiType === 'function_call' || apiType === 'tool_call') return 'tool_call';
124
+ return 'text';
125
+ }
126
+
127
+ /** Provider block callback: first sighting of an index starts it, a second completes it. */
128
+ onProviderBlock(index: number, block: unknown): void {
129
+ this.lastSeen.set(index, block);
130
+ if (!this.started.has(index)) {
131
+ const mbType = NativeBlockTracker.mapApiBlockType((block as { type?: string } | undefined)?.type);
132
+ this.started.set(index, mbType);
133
+ this.currentType = mbType;
134
+ this.blockIndex = index;
135
+ this.emit?.({ event: 'block_start', index, block: { type: mbType } });
136
+ return;
137
+ }
138
+ this.complete(index, block);
139
+ }
140
+
141
+ /**
142
+ * Complete every started block that never received its second callback.
143
+ * Run after the provider stream has returned; idempotent, and a no-op for
144
+ * paired-callback adapters.
145
+ */
146
+ flush(): void {
147
+ for (const index of this.started.keys()) {
148
+ if (!this.completed.has(index)) this.complete(index, this.lastSeen.get(index));
149
+ }
150
+ }
151
+
152
+ /** Discard tracking state (refusal retry rolled the attempt back). */
153
+ reset(): void {
154
+ this.currentType = 'text';
155
+ this.blockIndex = 0;
156
+ this.started.clear();
157
+ this.completed.clear();
158
+ this.lastSeen.clear();
159
+ }
160
+
161
+ private complete(index: number, block: unknown): void {
162
+ if (this.completed.has(index)) return;
163
+ this.completed.add(index);
164
+ const mbType = this.started.get(index)
165
+ ?? NativeBlockTracker.mapApiBlockType((block as { type?: string } | undefined)?.type);
166
+ const apiBlock = block as {
167
+ text?: string;
168
+ thinking?: string;
169
+ id?: string;
170
+ name?: string;
171
+ input?: unknown;
172
+ } | undefined;
173
+ const mb: MembraneBlock = { type: mbType };
174
+ if (mbType === 'text') mb.content = apiBlock?.text;
175
+ else if (mbType === 'thinking') mb.content = apiBlock?.thinking;
176
+ else if (mbType === 'tool_call') {
177
+ mb.toolId = apiBlock?.id;
178
+ mb.toolName = apiBlock?.name;
179
+ mb.input = apiBlock?.input as Record<string, unknown> | undefined;
180
+ }
181
+ this.emit?.({ event: 'block_complete', index, block: mb });
182
+ }
183
+ }
184
+
71
185
  export class Membrane {
72
186
  private adapter: ProviderAdapter;
73
187
  private registry?: ModelRegistry;
@@ -109,12 +223,20 @@ export class Membrane {
109
223
  // refusal is a successful HTTP call with an unwanted verdict, and letting
110
224
  // it consume error retries would couple two unrelated budgets.
111
225
  let refusalRetriesUsed = 0;
226
+ // Spend on attempts we threw away. A refused attempt is a completed,
227
+ // billed HTTP call; reporting only the surviving attempt's usage
228
+ // under-reports the turn by one full call per retry.
229
+ let discardedUsage: DiscardedAttemptsUsage | undefined;
230
+
231
+ // One selection for the whole call: mode resolution and the build must
232
+ // name the same formatter instance (see resolveActiveFormatter).
233
+ const activeFormatter = this.resolveActiveFormatter(options.formatter);
112
234
 
113
235
  while (true) {
114
236
  attempts++;
115
237
 
116
238
  try {
117
- const { providerRequest, prefillResult } = this.transformRequest(request, options.formatter);
239
+ const { providerRequest, prefillResult } = this.transformRequest(request, activeFormatter);
118
240
 
119
241
  // Route through the single canonical hook helper so any future
120
242
  // change to hook semantics (logging, retry interaction, error
@@ -123,7 +245,11 @@ export class Membrane {
123
245
  // `unknown` deliberately, and we acknowledge the cast at the boundary.
124
246
  const finalRequest = (await this.applyBeforeRequestHook(request, providerRequest)) as typeof providerRequest;
125
247
 
126
- const providerResponse = await this.adapter.complete(finalRequest, {
248
+ // Last exit before the adapter: the only place that sees EVERY
249
+ // contribution (builder, formatter, passthrough, float, hook).
250
+ clampCacheMarkers(finalRequest, 'complete');
251
+
252
+ const rawProviderResponse = await this.adapter.complete(finalRequest, {
127
253
  signal: options.signal,
128
254
  timeoutMs: options.timeoutMs,
129
255
  onRequest: (req) => {
@@ -131,6 +257,15 @@ export class Membrane {
131
257
  options.onRequest?.(req);
132
258
  },
133
259
  });
260
+ // Restate usage in the one convention before any ratio or price sees it.
261
+ const providerResponse: ProviderResponse = {
262
+ ...rawProviderResponse,
263
+ usage: normalizeUsageToCacheExcluded(
264
+ rawProviderResponse.usage,
265
+ this.adapter.name,
266
+ this.adapter.usageCacheConvention,
267
+ ),
268
+ };
134
269
 
135
270
  // Call onResponse callback with raw response from API
136
271
  options.onResponse?.(providerResponse.raw);
@@ -154,9 +289,21 @@ export class Membrane {
154
289
  refusalRetriesUsed < Math.max(0, options.refusalRetries ?? 0)
155
290
  ) {
156
291
  refusalRetriesUsed++;
292
+ discardedUsage = this.mergeDiscardedAttempts(
293
+ discardedUsage,
294
+ this.discardedAttemptFrom(response.usage)
295
+ );
157
296
  continue;
158
297
  }
159
298
 
299
+ // Report what the discarded attempts cost. Set BEFORE afterResponse
300
+ // so a hook that logs spend sees the whole turn, not just the
301
+ // attempt that stands.
302
+ if (discardedUsage) {
303
+ response.details.usage.discardedAttempts =
304
+ this.pricedDiscardedAttempts(discardedUsage, request.config.model);
305
+ }
306
+
160
307
  // Call afterResponse hook
161
308
  if (this.config.hooks?.afterResponse) {
162
309
  return await this.config.hooks.afterResponse(response, providerResponse.raw);
@@ -195,9 +342,16 @@ export class Membrane {
195
342
  }
196
343
  }
197
344
 
198
- // Wait before retry (abort-aware)
345
+ // Wait before retry (abort-aware). An abort landing inside the
346
+ // sleep must fail like every other failure of this method — a
347
+ // MembraneError — rather than escaping the loop as a raw
348
+ // DOMException whose shape no caller of complete() expects.
199
349
  const delay = this.calculateRetryDelay(attempts, isOverloaded);
200
- await this.sleep(delay, options.signal);
350
+ try {
351
+ await this.sleep(delay, options.signal);
352
+ } catch (sleepError) {
353
+ throw this.attachRawRequest(sleepError, rawRequest);
354
+ }
201
355
  continue;
202
356
  }
203
357
 
@@ -231,6 +385,20 @@ export class Membrane {
231
385
  // If streaming is explicitly disabled on the request, fall back to complete()
232
386
  // and synthesize the streaming callbacks from the full response
233
387
  if (request.streaming === false) {
388
+ // complete() has no tool loop, and neither branch of this fallback can
389
+ // build one: honouring onToolCalls here would mean re-implementing the
390
+ // whole XML/native continuation machinery. Silently dropping it turned
391
+ // a working agent into one that narrates tool calls it never makes —
392
+ // the raw <function_calls> XML lands in the returned text and the turn
393
+ // ends. Refuse where the option is passed, before spending a call.
394
+ if (options.onToolCalls) {
395
+ throw unsupportedError(
396
+ 'stream() cannot execute tools with streaming: false — the non-streaming ' +
397
+ 'fallback routes to complete(), which has no tool loop, so onToolCalls ' +
398
+ 'would never run. Leave streaming enabled (or drive the loop yourself ' +
399
+ 'with complete() per round).'
400
+ );
401
+ }
234
402
  const response = await this.complete(request, options);
235
403
  // Synthesize onChunk callbacks so callers that depend on them still work
236
404
  if (options.onChunk && 'content' in response) {
@@ -248,8 +416,9 @@ export class Membrane {
248
416
  return response;
249
417
  }
250
418
 
251
- // Determine tool mode
252
- const toolMode = this.resolveToolMode(request);
419
+ // Determine tool mode against the formatter that will build the request
420
+ const activeFormatter = this.resolveActiveFormatter(options.formatter);
421
+ const toolMode = this.resolveToolMode(request, activeFormatter);
253
422
  const useNative = toolMode === 'native' && !!request.tools && request.tools.length > 0;
254
423
 
255
424
  // Overloaded (529) pre-emission retry. The streaming paths have no retry
@@ -279,14 +448,16 @@ export class Membrane {
279
448
 
280
449
  try {
281
450
  const result = useNative
282
- ? await this.streamWithNativeTools(request, tracked)
283
- : await this.streamWithXmlTools(request, tracked);
284
- // The inner paths report attempts: 1 they can't see this wrapper.
285
- // A call that succeeded after N overloaded retries must not look like
286
- // a first-attempt success in durable logs, so patch the real count
287
- // (and the waits) into the response telemetry.
451
+ ? await this.streamWithNativeTools(request, tracked, activeFormatter)
452
+ : await this.streamWithXmlTools(request, tracked, activeFormatter);
453
+ // The inner paths count their own provider calls but cannot see this
454
+ // wrapper's discarded attempts. Each failed attempt here died before
455
+ // emitting anything (that is the precondition for retrying), so it
456
+ // cost at least the one call it failed on — ADD those to the inner
457
+ // count rather than overwriting it, or a turn that retried twice and
458
+ // then ran three tool rounds would report 2 calls instead of 5.
288
459
  if (attempts > 1 && 'details' in result) {
289
- result.details.timing.attempts = attempts;
460
+ result.details.timing.attempts += attempts - 1;
290
461
  result.details.timing.retryDelaysMs = retryDelaysMs;
291
462
  }
292
463
  return result;
@@ -313,7 +484,26 @@ export class Membrane {
313
484
  }
314
485
  const delay = this.calculateRetryDelay(attempts, true);
315
486
  retryDelaysMs.push(delay);
316
- await this.sleep(delay, options.signal);
487
+ // An abort during the backoff window is still a cancellation of
488
+ // this stream, and stream() documents cancellation as an
489
+ // AbortedResponse. Letting the sleep's rejection escape made that
490
+ // contract depend on which millisecond the abort landed in.
491
+ // Nothing has been emitted on this path (that is the precondition
492
+ // for retrying at all), so there is no partial content to report.
493
+ try {
494
+ await this.sleep(delay, options.signal);
495
+ } catch (sleepError) {
496
+ if (this.isAbortError(sleepError)) {
497
+ return this.buildAbortedResponse(
498
+ '',
499
+ { inputTokens: 0, outputTokens: 0 },
500
+ [],
501
+ [],
502
+ this.abortReason(sleepError, options.signal)
503
+ );
504
+ }
505
+ throw sleepError;
506
+ }
317
507
  continue;
318
508
  }
319
509
  throw error;
@@ -322,18 +512,68 @@ export class Membrane {
322
512
  }
323
513
 
324
514
  /**
325
- * Determine the effective tool mode
515
+ * Select the ACTIVE formatter for a request: the one instance that resolves
516
+ * its tool mode, builds its provider request, and parses its stream.
517
+ *
518
+ * A per-request override (`CompleteOptions.formatter` /
519
+ * `StreamOptions.formatter`) wins over the instance formatter, with ONE
520
+ * transport exception: the Responses adapter's input is a provider-native
521
+ * item array, and a generic override (for example Context Manager's
522
+ * NativeFormatter) produces Anthropic-style `{ role, content: [{ type:
523
+ * 'text' }] }` envelopes the Responses API rejects before inference — so a
524
+ * configured Responses formatter stays authoritative there.
525
+ *
526
+ * The exception is why this selection is a method rather than a `??` at each
527
+ * call site: while it lived inside transformRequest alone, the BUILD honored
528
+ * it and every other formatter reader resolved against a different instance,
529
+ * which is the split resolveToolMode exists to prevent, one layer down.
530
+ * Every entry point selects once, here, and threads the result.
531
+ */
532
+ private resolveActiveFormatter(requestFormatter?: PrefillFormatter): PrefillFormatter {
533
+ if (this.adapter.name === 'openai-responses-api' && this.formatter.name === 'openai-responses') {
534
+ return this.formatter;
535
+ }
536
+ return requestFormatter ?? this.formatter;
537
+ }
538
+
539
+ /**
540
+ * Determine the effective tool mode.
541
+ *
542
+ * THE single source of truth for the mode: both complete() (via
543
+ * transformRequest → BuildOptions.toolMode) and the streaming paths (via
544
+ * their native-vs-XML path choice) resolve here, so a given request resolves
545
+ * to the same mode whichever entry point it arrives through.
546
+ *
547
+ * Precedence, strongest first:
548
+ * 1. an explicit non-'auto' `request.toolMode`
549
+ * 2. the mode the BUILDING formatter was explicitly constructed with
550
+ * (`AnthropicXmlFormatter({ toolMode: 'native' })`) — a caller's stated
551
+ * choice, not a derivation
552
+ * 3. formatter/provider derivation
553
+ *
554
+ * `formatter` is the formatter that will actually build the request — the
555
+ * instance `resolveActiveFormatter` selected for this call — because
556
+ * resolving against one formatter while building with another is exactly the
557
+ * split this method exists to prevent.
326
558
  */
327
- private resolveToolMode(request: NormalizedRequest): ToolMode {
559
+ private resolveToolMode(
560
+ request: NormalizedRequest,
561
+ formatter: PrefillFormatter = this.formatter
562
+ ): 'xml' | 'native' {
328
563
  // Explicit mode takes precedence
329
564
  if (request.toolMode && request.toolMode !== 'auto') {
330
565
  return request.toolMode;
331
566
  }
332
567
 
568
+ // A formatter constructed with an explicit mode states its caller's choice
569
+ if (formatter.configuredToolMode) {
570
+ return formatter.configuredToolMode;
571
+ }
572
+
333
573
  // Auto mode: choose based on formatter
334
574
  // NativeFormatter → native tools via API
335
575
  // AnthropicXmlFormatter (default) → XML tools in prefill
336
- if (this.formatter.name === 'native' || this.formatter.name === 'openai-responses') {
576
+ if (formatter.name === 'native' || formatter.name === 'openai-responses') {
337
577
  return 'native';
338
578
  }
339
579
 
@@ -355,7 +595,8 @@ export class Membrane {
355
595
  */
356
596
  private async streamWithXmlTools(
357
597
  request: NormalizedRequest,
358
- options: StreamOptions
598
+ options: StreamOptions,
599
+ activeFormatter: PrefillFormatter = this.resolveActiveFormatter(options.formatter)
359
600
  ): Promise<NormalizedResponse | AbortedResponse> {
360
601
  const startTime = Date.now();
361
602
  const {
@@ -369,17 +610,30 @@ export class Membrane {
369
610
  onResponse,
370
611
  maxToolDepth = 10,
371
612
  signal,
372
- formatter: requestFormatter,
613
+ timeoutMs,
614
+ idleTimeoutMs,
373
615
  } = options;
374
616
 
375
- // Use per-request formatter if provided, otherwise use instance formatter
376
- const formatter = requestFormatter ?? this.formatter;
617
+ // The formatter stream() selected: the same instance that resolved the
618
+ // mode and will build the request, so the parser can never be reading a
619
+ // different format than the one on the wire.
620
+ const formatter = activeFormatter;
377
621
 
378
622
  // Initialize parser from formatter for format-specific tracking
379
623
  const parser = formatter.createStreamParser();
380
624
  let toolDepth = 0;
381
- let totalUsage: DetailedUsage = { inputTokens: 0, outputTokens: 0 };
382
- const pricing = this.resolvePricing(request.config.model);
625
+ // Each round is priced under the model that served THAT round and the
626
+ // costs are summed: a routed turn can change models mid-turn, and pricing
627
+ // the whole accumulated usage at the latest rate re-bills every earlier
628
+ // round at a price it was never charged.
629
+ const turnUsage = new TurnUsageAccumulator(
630
+ request.config.model,
631
+ (servedModel) => this.resolvePricing(request.config.model, servedModel),
632
+ );
633
+ // Honest turn telemetry: provider calls actually made (including refusal
634
+ // re-issues inside streamOnce) and continuation rounds.
635
+ let providerCalls = 0;
636
+ let rounds = 0;
383
637
  const contentBlocks: ContentBlock[] = [];
384
638
  let lastStopReason: StopReason = 'end_turn';
385
639
  let lastStopSequence: string | undefined;
@@ -463,7 +717,7 @@ export class Membrane {
463
717
  if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
464
718
  warnLog.warn(
465
719
  `[membrane] automatic resumption at round ${resumptionRounds} ` +
466
- `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
720
+ `(${turnUsage.total.inputTokens} input tokens so far this turn) — ` +
467
721
  `a spin shows up here before it shows up on the bill`
468
722
  );
469
723
  }
@@ -471,7 +725,7 @@ export class Membrane {
471
725
  warnLog.warn(
472
726
  `[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
473
727
  `ending turn with stopReason 'round_limit'. ` +
474
- `${totalUsage.inputTokens} input tokens spent this turn.`
728
+ `${turnUsage.total.inputTokens} input tokens spent this turn.`
475
729
  );
476
730
  return false;
477
731
  }
@@ -547,6 +801,8 @@ export class Membrane {
547
801
  },
548
802
  {
549
803
  signal,
804
+ timeoutMs,
805
+ idleTimeoutMs,
550
806
  normalizedRequest: request,
551
807
  // The tag-based parser tracks thinking via <thinking> tags — ask the
552
808
  // provider to wrap native thinking deltas so they don't stream as
@@ -559,6 +815,9 @@ export class Membrane {
559
815
  }
560
816
  );
561
817
 
818
+ rounds++;
819
+ providerCalls += streamResult.providerCalls;
820
+
562
821
  // If we detected stop sequence manually, fix up the parser and result
563
822
  if (detectedStopSequence && truncatedAccumulated !== null) {
564
823
  parser.reset();
@@ -593,17 +852,11 @@ export class Membrane {
593
852
  lastStopReason = this.mapStopReason(streamResult.stopReason);
594
853
  lastStopSequence = streamResult.stopSequence ?? undefined;
595
854
 
596
- // Accumulate usage (including cache metrics)
597
- totalUsage.inputTokens += streamResult.usage.inputTokens;
598
- totalUsage.outputTokens += streamResult.usage.outputTokens;
599
- if (streamResult.usage.cacheCreationTokens) {
600
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
601
- }
602
- if (streamResult.usage.cacheReadTokens) {
603
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
604
- }
605
- if (pricing) totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
606
- onUsage?.(totalUsage);
855
+ // Accumulate usage (including cache metrics), priced at this round's
856
+ // model. NOT inlined into the optional call — `onUsage?.(addRound())`
857
+ // skips evaluating its argument entirely when no callback is set.
858
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
859
+ onUsage?.(usageSoFar);
607
860
 
608
861
  // Flush the parser to complete any in-progress streaming block
609
862
  const flushResult = parser.flush();
@@ -637,7 +890,7 @@ export class Membrane {
637
890
  `[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
638
891
  `made no progress (${streamedThisRound} chars this round, stop ` +
639
892
  `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
640
- `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
893
+ `stopReason 'no_progress'. ${turnUsage.total.inputTokens} input tokens spent this turn.`
641
894
  );
642
895
  lastStopReason = 'no_progress';
643
896
  break;
@@ -919,17 +1172,17 @@ export class Membrane {
919
1172
  newContent,
920
1173
  contentBlocks,
921
1174
  lastStopReason,
922
- totalUsage,
1175
+ turnUsage,
923
1176
  request,
924
1177
  prefillResult,
925
1178
  startTime,
926
- 1, // attempts
1179
+ providerCalls,
927
1180
  rawRequest,
928
1181
  rawResponse,
929
1182
  executedToolCalls,
930
1183
  executedToolResults,
931
1184
  initialBlockType,
932
- lastStopSequence
1185
+ lastStopSequence,
933
1186
  );
934
1187
 
935
1188
  // Append non-text content blocks (e.g., generated_image) that the XML parser can't handle
@@ -940,6 +1193,8 @@ export class Membrane {
940
1193
  // Merge provider thinking signatures into parser-derived thinking blocks
941
1194
  this.mergeProviderThinkingBlocks(response.content, providerThinkingBlocks);
942
1195
 
1196
+ response.details.timing.rounds = rounds;
1197
+
943
1198
  return response;
944
1199
  } catch (error) {
945
1200
  // Check if this is an abort error
@@ -950,10 +1205,10 @@ export class Membrane {
950
1205
 
951
1206
  return this.buildAbortedResponse(
952
1207
  newContent,
953
- totalUsage,
1208
+ turnUsage.total,
954
1209
  executedToolCalls,
955
1210
  executedToolResults,
956
- 'user',
1211
+ this.abortReason(error, signal),
957
1212
  initialBlockType
958
1213
  );
959
1214
  }
@@ -967,12 +1222,14 @@ export class Membrane {
967
1222
  */
968
1223
  private async streamWithNativeTools(
969
1224
  request: NormalizedRequest,
970
- options: StreamOptions
1225
+ options: StreamOptions,
1226
+ activeFormatter: PrefillFormatter = this.resolveActiveFormatter(options.formatter)
971
1227
  ): Promise<NormalizedResponse | AbortedResponse> {
972
1228
  const startTime = Date.now();
973
1229
  const {
974
1230
  onChunk,
975
1231
  onContentBlockUpdate,
1232
+ onBlock,
976
1233
  onToolCalls,
977
1234
  onPreToolContent,
978
1235
  onUsage,
@@ -980,11 +1237,21 @@ export class Membrane {
980
1237
  onResponse,
981
1238
  maxToolDepth = 10,
982
1239
  signal,
1240
+ timeoutMs,
1241
+ idleTimeoutMs,
983
1242
  } = options;
984
1243
 
985
1244
  let toolDepth = 0;
986
- let totalUsage: DetailedUsage = { inputTokens: 0, outputTokens: 0 };
987
- const pricing = this.resolvePricing(request.config.model);
1245
+ // See streamWithXmlTools: one accumulator per turn, pricing each round
1246
+ // under the model that served it.
1247
+ const turnUsage = new TurnUsageAccumulator(
1248
+ request.config.model,
1249
+ (servedModel) => this.resolvePricing(request.config.model, servedModel),
1250
+ );
1251
+ // Honest turn telemetry: provider calls actually made (including refusal
1252
+ // re-issues inside streamOnce) and continuation rounds.
1253
+ let providerCalls = 0;
1254
+ let rounds = 0;
988
1255
  let lastStopReason: StopReason = 'end_turn';
989
1256
  let lastStopSequence: string | undefined;
990
1257
  let rawRequest: unknown;
@@ -1000,45 +1267,67 @@ export class Membrane {
1000
1267
  // Build messages array that we'll update with tool results
1001
1268
  let messages = [...request.messages];
1002
1269
  let allContentBlocks: ContentBlock[] = [];
1270
+ let markersInLastRequest = 0;
1003
1271
 
1004
1272
  try {
1005
1273
  // Tool execution loop
1006
1274
  while (toolDepth <= maxToolDepth) {
1007
1275
  // Build provider request with native tools
1008
- const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0);
1276
+ const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0, activeFormatter);
1009
1277
 
1010
1278
  // Stream from provider
1011
1279
  let textAccumulated = '';
1012
- let blockIndex = 0;
1280
+ // Tag every token chunk with the membrane block it belongs to and
1281
+ // surface the block lifecycle through onBlock — the same shape
1282
+ // runNativeToolsYielding uses (#19). Before this, meta.type was
1283
+ // hardcoded to 'text' on every chunk and onBlock was never invoked
1284
+ // from this path (#20).
1285
+ const tracker = new NativeBlockTracker(onBlock ? (event) => onBlock(event) : undefined);
1013
1286
  const streamResult = await this.streamOnce(
1014
1287
  providerRequest,
1015
1288
  {
1016
1289
  onChunk: (chunk) => {
1017
1290
  textAccumulated += chunk;
1018
1291
  allTextAccumulated += chunk;
1019
- // For native mode, emit text chunks with basic metadata
1020
- // TODO: Use native API content_block events for richer metadata
1021
1292
  const meta: ChunkMeta = {
1022
- type: 'text',
1023
- visible: true,
1024
- blockIndex,
1293
+ type: tracker.currentType,
1294
+ visible: tracker.currentType === 'text',
1295
+ blockIndex: tracker.blockIndex,
1025
1296
  };
1026
1297
  onChunk?.(chunk, meta);
1027
1298
  },
1028
- onContentBlock: onContentBlockUpdate
1029
- ? (index: number, block: unknown) => onContentBlockUpdate(index, block as ContentBlock)
1030
- : undefined,
1299
+ onContentBlock: (index: number, block: unknown) => {
1300
+ tracker.onProviderBlock(index, block);
1301
+ // Deprecated pass-through, kept for callers still on it.
1302
+ onContentBlockUpdate?.(index, block as ContentBlock);
1303
+ },
1031
1304
  },
1032
1305
  {
1033
1306
  signal,
1307
+ timeoutMs,
1308
+ idleTimeoutMs,
1034
1309
  normalizedRequest: request,
1035
1310
  onRequest: (req) => {
1036
1311
  rawRequest = req;
1037
1312
  onRequest?.(req);
1038
1313
  },
1314
+ // Telemetry reports what this request actually SHIPPED with —
1315
+ // builder breakpoints, stale passthrough, fallback, float, plus
1316
+ // whatever the beforeRequest hook and the wire clamp did after
1317
+ // the build. Both native paths used to hardcode 0, and counting
1318
+ // at build time reported a number no request ever had.
1319
+ onWireCacheMarkers: (markerCount) => {
1320
+ markersInLastRequest = markerCount;
1321
+ },
1039
1322
  }
1040
1323
  );
1041
1324
 
1325
+ // Single-callback adapters (OpenAI Responses) report each finalised
1326
+ // block once, after the stream: complete whatever never saw a stop.
1327
+ tracker.flush();
1328
+ rounds++;
1329
+ providerCalls += streamResult.providerCalls;
1330
+
1042
1331
  rawResponse = streamResult.raw;
1043
1332
 
1044
1333
  // Call onResponse callback with raw response from API
@@ -1047,17 +1336,11 @@ export class Membrane {
1047
1336
  lastStopReason = this.mapStopReason(streamResult.stopReason);
1048
1337
  lastStopSequence = streamResult.stopSequence ?? undefined;
1049
1338
 
1050
- // Accumulate usage (including cache metrics)
1051
- totalUsage.inputTokens += streamResult.usage.inputTokens;
1052
- totalUsage.outputTokens += streamResult.usage.outputTokens;
1053
- if (streamResult.usage.cacheCreationTokens) {
1054
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
1055
- }
1056
- if (streamResult.usage.cacheReadTokens) {
1057
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
1058
- }
1059
- if (pricing) totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
1060
- onUsage?.(totalUsage);
1339
+ // Accumulate usage (including cache metrics), priced at this round's
1340
+ // model. NOT inlined into the optional call — `onUsage?.(addRound())`
1341
+ // skips evaluating its argument entirely when no callback is set.
1342
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
1343
+ onUsage?.(usageSoFar);
1061
1344
 
1062
1345
  // Parse content blocks from response
1063
1346
  const responseBlocks = this.parseProviderContent(streamResult.content);
@@ -1145,6 +1428,7 @@ export class Membrane {
1145
1428
  }
1146
1429
 
1147
1430
  const durationMs = Date.now() - startTime;
1431
+ const totalUsage = turnUsage.total;
1148
1432
 
1149
1433
  return {
1150
1434
  content: allContentBlocks,
@@ -1162,15 +1446,17 @@ export class Membrane {
1162
1446
  usage: { ...totalUsage },
1163
1447
  timing: {
1164
1448
  totalDurationMs: durationMs,
1165
- attempts: 1,
1449
+ attempts: providerCalls,
1450
+ rounds,
1166
1451
  },
1167
1452
  model: {
1168
1453
  requested: request.config.model,
1169
- actual: request.config.model,
1454
+ actual: turnUsage.lastServedModel || request.config.model,
1170
1455
  provider: this.adapter.name,
1456
+ perRound: turnUsage.perRound,
1171
1457
  },
1172
1458
  cache: {
1173
- markersInRequest: 0,
1459
+ markersInRequest: markersInLastRequest,
1174
1460
  tokensCreated: totalUsage.cacheCreationTokens ?? 0,
1175
1461
  tokensRead: totalUsage.cacheReadTokens ?? 0,
1176
1462
  hitRatio: this.calculateCacheHitRatio(totalUsage),
@@ -1186,10 +1472,10 @@ export class Membrane {
1186
1472
  if (this.isAbortError(error)) {
1187
1473
  return this.buildAbortedResponse(
1188
1474
  allTextAccumulated,
1189
- totalUsage,
1475
+ turnUsage.total,
1190
1476
  executedToolCalls,
1191
1477
  executedToolResults,
1192
- 'user'
1478
+ this.abortReason(error, signal)
1193
1479
  );
1194
1480
  }
1195
1481
  // Re-throw with rawRequest attached for logging
@@ -1197,8 +1483,37 @@ export class Membrane {
1197
1483
  }
1198
1484
  }
1199
1485
 
1200
- /** See the floating-cache-marker block in buildNativeToolRequest. */
1201
- private floatBudgetWarned = false;
1486
+ /**
1487
+ * Rate-limit state for the float's budget warning. See the
1488
+ * floating-cache-marker block in buildNativeToolRequest.
1489
+ *
1490
+ * A once-per-instance latch made the ONLY observable of an over-budget wire
1491
+ * go quiet for the life of the process: a long-lived Membrane warns for the
1492
+ * first agent that trips it and never again, so the condition looks like it
1493
+ * healed. Warn on the first occurrence, then at most once per interval,
1494
+ * carrying the count of what was suppressed in between.
1495
+ */
1496
+ private floatBudgetWarnState = { lastWarnedAtMs: 0, suppressedSinceWarn: 0 };
1497
+ private static readonly FLOAT_BUDGET_WARN_INTERVAL_MS = 60_000;
1498
+
1499
+ private warnFloatBudgetExhausted(wireMarkers: number): void {
1500
+ const now = Date.now();
1501
+ const state = this.floatBudgetWarnState;
1502
+ const elapsed = now - state.lastWarnedAtMs;
1503
+ if (state.lastWarnedAtMs !== 0 && elapsed < Membrane.FLOAT_BUDGET_WARN_INTERVAL_MS) {
1504
+ state.suppressedSinceWarn++;
1505
+ return;
1506
+ }
1507
+ const suppressed = state.suppressedSinceWarn;
1508
+ state.lastWarnedAtMs = now;
1509
+ state.suppressedSinceWarn = 0;
1510
+ console.warn(
1511
+ `[membrane] floating cache marker withheld: upstream markers already ` +
1512
+ `occupy all ${MAX_CACHE_BREAKPOINTS} cache_control slots (${wireMarkers} on the wire). ` +
1513
+ `Tool-round suffixes will not cache incrementally.` +
1514
+ (suppressed > 0 ? ` (${suppressed} further occurrences suppressed since the last warning.)` : '')
1515
+ );
1516
+ }
1202
1517
 
1203
1518
  /**
1204
1519
  * Build a provider request with native tool support.
@@ -1206,18 +1521,25 @@ export class Membrane {
1206
1521
  * `toolLoopRebuild` is true when this build is a tool-loop continuation
1207
1522
  * (toolDepth > 0) rather than the turn's first request — the only case
1208
1523
  * where the floating cache marker applies.
1524
+ *
1525
+ * `activeFormatter` is the formatter the caller selected for the request
1526
+ * (see resolveActiveFormatter). Reading `this.formatter` here instead made
1527
+ * the native loop build through the instance formatter while the mode had
1528
+ * been resolved against a per-request override — the two disagreeing about
1529
+ * which formatter is active.
1209
1530
  */
1210
1531
  private buildNativeToolRequest(
1211
1532
  request: NormalizedRequest,
1212
1533
  messages: typeof request.messages,
1213
- toolLoopRebuild = false
1534
+ toolLoopRebuild = false,
1535
+ activeFormatter: PrefillFormatter = this.formatter
1214
1536
  ): any {
1215
1537
  // Provider-native formatters own their complete input-item shape. The
1216
1538
  // legacy implementation below is intentionally Anthropic-specific; using
1217
1539
  // it for Responses would normalize away item IDs, encrypted reasoning,
1218
1540
  // assistant phases, and compaction items.
1219
- if (this.formatter.name === 'openai-responses') {
1220
- return this.transformRequest({ ...request, messages }, this.formatter).providerRequest;
1541
+ if (activeFormatter.name === 'openai-responses') {
1542
+ return this.transformRequest({ ...request, messages }, activeFormatter).providerRequest;
1221
1543
  }
1222
1544
 
1223
1545
  // Convert messages to provider format
@@ -1232,11 +1554,13 @@ export class Membrane {
1232
1554
  // Anthropic allows at most 4 cache_control breakpoints per request. The
1233
1555
  // message breakpoints are the valuable ones (they cache the longest prefixes,
1234
1556
  // and every one already includes tools+system at the front of the request).
1235
- // So tools/system get a breakpoint only as a FALLBACK — when no message
1236
- // breakpoint was marked — otherwise they're redundant and would push the
1237
- // total past 4, which the API hard-rejects (the agent goes unresponsive).
1238
- let messageBreakpoints = 0;
1239
-
1557
+ // So tools/system get a breakpoint only as a FALLBACK — when no marker
1558
+ // exists anywhere on the wire — otherwise they're redundant and would push
1559
+ // the total past 4, which the API hard-rejects (the agent goes
1560
+ // unresponsive). The fallback gate reads a RECOUNT of the built artifacts
1561
+ // (see below), never a running tally: a running tally cannot see a
1562
+ // caller-marked system block, and double-counts a message breakpoint that
1563
+ // lands on a block already carrying stale cache_control.
1240
1564
  for (const msg of messages) {
1241
1565
  const isAssistant = msg.participant === assistantName;
1242
1566
  const role = isAssistant ? 'assistant' : 'user';
@@ -1257,14 +1581,13 @@ export class Membrane {
1257
1581
  }
1258
1582
  const textBlock: Record<string, unknown> = { type: 'text', text };
1259
1583
  if ((block as any).cache_control) {
1260
- textBlock.cache_control = (block as any).cache_control;
1261
1584
  // A block-level passthrough occupies one of the 4 breakpoint slots
1262
- // exactly like a marked message count it, so the tools/system
1263
- // fallback below doesn't stack more on top. (Imported/seeded
1264
- // conversations can carry stale request-time cache_control on
1265
- // stored blocks — first seen wedging Sill 2026-07-25: 3 cm markers
1266
- // + 2 stale Arc-export blocks = 5 → hard 400 on every inference.)
1267
- messageBreakpoints++;
1585
+ // exactly like a marked message; the recount below sees it.
1586
+ // (Imported/seeded conversations carry stale request-time
1587
+ // cache_control on stored blocks first seen wedging Sill
1588
+ // 2026-07-25: 3 cm markers + 2 stale Arc-export blocks = 5 → hard
1589
+ // 400 on every inference.)
1590
+ textBlock.cache_control = (block as any).cache_control;
1268
1591
  }
1269
1592
  content.push(textBlock);
1270
1593
  } else if (block.type === 'tool_use') {
@@ -1336,7 +1659,6 @@ export class Membrane {
1336
1659
  const bpIdx = lastCacheableBlockIndex(content as Array<Record<string, unknown>>);
1337
1660
  if (bpIdx >= 0) {
1338
1661
  content[bpIdx].cache_control = cacheControl;
1339
- messageBreakpoints++;
1340
1662
  }
1341
1663
  }
1342
1664
 
@@ -1367,14 +1689,32 @@ export class Membrane {
1367
1689
  // past one. `synthetic_pending_result` (not the downstream
1368
1690
  // cache_suppressed_for_synthetic, which only fires when a marker was
1369
1691
  // actually stripped) is the root condition.
1370
- let pendingResultSynthesized = false;
1692
+ // Every repair that REWRITES prefix bytes stands the float down, not just
1693
+ // the synthetic [pending] result: a textified orphan tool_result is
1694
+ // rewritten the same way when its real pairing arrives, so caching at or
1695
+ // past one poisons the prefix identically. The kinds live in one exported
1696
+ // set so a normalizer that grows a new prefix-rewriting repair cannot
1697
+ // silently escape this guard.
1698
+ let prefixRewritten = false;
1371
1699
  const normalized = normalizeToolPairs(providerMessages, {
1372
1700
  onEvent: (e) => {
1373
- if (e.kind === 'synthetic_pending_result') pendingResultSynthesized = true;
1701
+ if (PREFIX_REWRITING_NORMALIZE_EVENT_KINDS.has(e.kind)) prefixRewritten = true;
1374
1702
  },
1375
1703
  });
1376
1704
  const mergedMessages = mergeConsecutiveRoles(normalized.messages);
1377
1705
 
1706
+ // ONE recount of the constructed wire artifacts, taken BEFORE the
1707
+ // tools/system fallback decision so the fallback and the float share a
1708
+ // single truth. Counted post-normalize, so phase-5.5 cache suppression is
1709
+ // already reflected. `request.system` is the caller's own system content:
1710
+ // it explicitly accepts pre-marked blocks, and those are real wire markers
1711
+ // that no running tally ever saw (three of them plus both fallbacks = 5 on
1712
+ // the wire = a 400 on every inference of that config).
1713
+ const upstreamWireMarkers = countWireCacheMarkers({
1714
+ messages: mergedMessages,
1715
+ system: request.system,
1716
+ });
1717
+
1378
1718
  // Convert tools to provider format.
1379
1719
  // Native tool names must match ^[a-zA-Z0-9_-]{1,128}$ — sanitize colons
1380
1720
  // from the module:tool namespace convention. Reversed in parseProviderContent.
@@ -1386,7 +1726,7 @@ export class Membrane {
1386
1726
  };
1387
1727
  // Cache the tool list (last tool) only as a fallback — a marked message
1388
1728
  // breakpoint already caches the tools as part of its prefix.
1389
- if (cacheControl && messageBreakpoints === 0 && request.tools && idx === request.tools.length - 1) {
1729
+ if (cacheControl && upstreamWireMarkers === 0 && request.tools && idx === request.tools.length - 1) {
1390
1730
  t.cache_control = cacheControl;
1391
1731
  }
1392
1732
  return t;
@@ -1395,10 +1735,10 @@ export class Membrane {
1395
1735
  // Wrap system prompt with cache_control only as a fallback (no message
1396
1736
  // breakpoint marked); otherwise a message breakpoint already caches
1397
1737
  // tools+system as part of its prefix.
1398
- let system: unknown = request.system;
1399
- if (cacheControl && messageBreakpoints === 0 && typeof system === 'string' && system.length > 0) {
1738
+ let system: unknown = ownSystemBlocks(request.system);
1739
+ if (cacheControl && upstreamWireMarkers === 0 && typeof system === 'string' && system.length > 0) {
1400
1740
  system = [{ type: 'text', text: system, cache_control: cacheControl }];
1401
- } else if (cacheControl && messageBreakpoints === 0 && Array.isArray(system) && system.length > 0) {
1741
+ } else if (cacheControl && upstreamWireMarkers === 0 && Array.isArray(system) && system.length > 0) {
1402
1742
  const blocks = system as Record<string, unknown>[];
1403
1743
  system = blocks.map((block, idx) =>
1404
1744
  idx === blocks.length - 1 ? { ...block, cache_control: cacheControl } : block
@@ -1437,39 +1777,13 @@ export class Membrane {
1437
1777
  // ------------------------------------------------------------------
1438
1778
  const floatingEnabled =
1439
1779
  request.floatingCacheMarker ?? this.config.defaultFloatingCacheMarker ?? true;
1440
- if (toolLoopRebuild && floatingEnabled && cacheControl && !pendingResultSynthesized) {
1441
- // Residuum from a RECOUNT of the constructed wire artifacts, not the
1442
- // running messageBreakpoints tally the tally diverges from the wire
1443
- // in both directions (mirrors NativeFormatter's recount, same bug
1444
- // class as the Sill 2026-07-25 wedge): a message-level breakpoint
1445
- // landing on a block already carrying stale cache_control is one
1446
- // physical marker counted twice, and a pre-marked system block is a
1447
- // real wire marker the tally never sees. Counted post-fallback and
1448
- // post-normalize, so fallback spend and phase-5.5 suppression are
1449
- // both reflected.
1450
- let wireMarkers = 0;
1451
- for (const m of mergedMessages) {
1452
- if (!Array.isArray(m.content)) continue;
1453
- for (const b of m.content as Array<Record<string, unknown>>) {
1454
- if (b.cache_control) wireMarkers++;
1455
- }
1456
- }
1457
- if (tools) for (const t of tools) { if (t.cache_control) wireMarkers++; }
1458
- if (Array.isArray(system)) {
1459
- for (const b of system as Array<Record<string, unknown>>) {
1460
- if (b.cache_control) wireMarkers++;
1461
- }
1462
- }
1463
- let residuum = 4 - wireMarkers;
1780
+ if (toolLoopRebuild && floatingEnabled && cacheControl && !prefixRewritten) {
1781
+ // Same recount as the fallback gate, re-taken POST-fallback so the
1782
+ // fallback's own spend is inside the residuum.
1783
+ const wireMarkers = countWireCacheMarkers({ messages: mergedMessages, system, tools });
1784
+ let residuum = MAX_CACHE_BREAKPOINTS - wireMarkers;
1464
1785
  if (residuum <= 0) {
1465
- if (!this.floatBudgetWarned) {
1466
- this.floatBudgetWarned = true;
1467
- console.warn(
1468
- `[membrane] floating cache marker withheld: upstream markers already ` +
1469
- `occupy all 4 cache_control slots (${wireMarkers} on the wire). ` +
1470
- `Tool-round suffixes will not cache incrementally.`
1471
- );
1472
- }
1786
+ this.warnFloatBudgetExhausted(wireMarkers);
1473
1787
  } else {
1474
1788
  // Newest message first; then the previous round's endpoint (two
1475
1789
  // wire messages back: [..., prevResults, assistant, results]).
@@ -1538,6 +1852,9 @@ export class Membrane {
1538
1852
  id: item.id,
1539
1853
  name: unsanitizeToolName(item.name),
1540
1854
  input: item.input,
1855
+ // Arguments that never parsed: carry the marker through so a
1856
+ // consumer can refuse the block instead of trusting `input`.
1857
+ ...(item.unparseableInput !== undefined ? { unparseableInput: item.unparseableInput } : {}),
1541
1858
  ...(item.rawItem ? { rawItem: item.rawItem } : {}),
1542
1859
  });
1543
1860
  } else if (item.type === 'thinking') {
@@ -1556,14 +1873,23 @@ export class Membrane {
1556
1873
  data: item.data,
1557
1874
  mimeType: item.mimeType,
1558
1875
  });
1559
- } else if (item.rawItem) {
1876
+ } else if (item.rawItem || item.type) {
1560
1877
  // Opaque Responses items such as encrypted compaction or custom
1561
1878
  // tool records have no normalized ContentBlock equivalent. Retain a
1562
1879
  // zero-width carrier so Chronicle and the Responses formatter can
1563
1880
  // replay the raw item without surfacing synthetic prompt text.
1564
1881
  // Anthropic-bound conversion paths filter these out (empty text
1565
1882
  // blocks are a 400 there); the Responses formatter replays rawItem.
1566
- blocks.push({ type: 'text', text: '', rawItem: item.rawItem });
1883
+ //
1884
+ // An item with a `type` this switch does not know (server_tool_use,
1885
+ // web_search_tool_result, search_result, mcp_tool_use, or whatever a
1886
+ // provider adds next) used to fall out of this chain and vanish. It
1887
+ // gets the same carrier treatment, holding the item itself, plus a
1888
+ // one-time warning so the gap surfaces instead of being inferred
1889
+ // later from missing content.
1890
+ const carriedRawItem = item.rawItem ?? item;
1891
+ if (!item.rawItem) warnUnconvertibleProviderItem(item.type);
1892
+ blocks.push({ type: 'text', text: '', rawItem: carriedRawItem });
1567
1893
  }
1568
1894
  }
1569
1895
  return blocks;
@@ -1602,10 +1928,32 @@ export class Membrane {
1602
1928
 
1603
1929
  /**
1604
1930
  * Merge provider thinking signatures into parser-derived thinking blocks
1605
- * (matched in stream order), and prepend any leftover provider blocks —
1606
- * signature-only thinking (display:'omitted') never appears in the text
1607
- * stream, so the parser produces no block for it. redacted_thinking
1608
- * blocks are always prepended verbatim.
1931
+ * and prepend any leftover provider blocks — signature-only thinking
1932
+ * (display:'omitted') never appears in the text stream, so the parser
1933
+ * produces no block for it. redacted_thinking blocks are always prepended
1934
+ * verbatim.
1935
+ *
1936
+ * Pairing is by CONTENT IDENTITY, never by index. The two lists are
1937
+ * differently shaped whenever the provider emits a block the parser cannot
1938
+ * see (signature-only), the parser emits a block the provider never
1939
+ * produced (the XML path's literal `Claude: <thinking>` prefill turns
1940
+ * VISIBLE text into a thinking block), or one provider block spans several
1941
+ * (auto-continuation: capture runs per round while the parser sees the
1942
+ * CONCATENATED accumulation). Index-zipping crosses the lists in all three
1943
+ * shapes and stamps a signature onto content that never produced it —
1944
+ * which round-trips into the consumer's stored history and fails Anthropic
1945
+ * signature validation on the next turn.
1946
+ *
1947
+ * The three rules, in order:
1948
+ * 1. identity — a provider block pairs with the parsed block whose
1949
+ * thinking text is the same; empty-thinking (signature-only) blocks
1950
+ * are never text-match candidates and are prepend-only.
1951
+ * 2. span — a parsed block that reconstructs as the concatenation of a
1952
+ * RUN of consecutive unpaired provider blocks is REPLACED in place by
1953
+ * those originals, so the spanning block never wears a fragment's
1954
+ * signature and no reasoning is sent twice.
1955
+ * 3. leftover — everything still unpaired is prepended, de-duplicated
1956
+ * against what `content` already carries (and against itself).
1609
1957
  *
1610
1958
  * Mutates `content` in place. Shared by the XML stream paths
1611
1959
  * (streamWithXmlTools and runXmlToolsYielding).
@@ -1616,25 +1964,74 @@ export class Membrane {
1616
1964
  ): void {
1617
1965
  if (providerThinkingBlocks.length === 0) return;
1618
1966
 
1619
- const parsedThinking = content.filter(
1967
+ const providerThinking = providerThinkingBlocks.filter(
1620
1968
  (b) => b.type === 'thinking'
1621
- ) as Array<{ type: 'thinking'; thinking: string; signature?: string }>;
1622
-
1623
- const providerThinking = providerThinkingBlocks.filter((b) => b.type === 'thinking');
1969
+ ) as Array<{ type: 'thinking'; thinking?: string; signature?: string }>;
1624
1970
  const redacted = providerThinkingBlocks.filter((b) => b.type === 'redacted_thinking');
1625
1971
 
1626
- const matched = Math.min(providerThinking.length, parsedThinking.length);
1627
- for (let i = 0; i < matched; i++) {
1628
- const sig = (providerThinking[i] as { signature?: string }).signature;
1629
- if (sig) {
1630
- parsedThinking[i]!.signature = sig;
1631
- }
1972
+ const pairedProviderBlocks = new Set<number>();
1973
+ const claimedParsedIndices = new Set<number>();
1974
+ const parsedThinkingIndices = () =>
1975
+ content.reduce<number[]>((acc, block, index) => {
1976
+ if (block.type === 'thinking') acc.push(index);
1977
+ return acc;
1978
+ }, []);
1979
+
1980
+ for (let p = 0; p < providerThinking.length; p++) {
1981
+ const providerText = providerThinking[p]!.thinking ?? '';
1982
+ if (providerText === '') continue;
1983
+ const match = parsedThinkingIndices().find(
1984
+ (index) =>
1985
+ !claimedParsedIndices.has(index) &&
1986
+ sameThinkingText((content[index] as { thinking?: string }).thinking ?? '', providerText)
1987
+ );
1988
+ if (match === undefined) continue;
1989
+ const signature = providerThinking[p]!.signature;
1990
+ if (signature) (content[match] as { signature?: string }).signature = signature;
1991
+ claimedParsedIndices.add(match);
1992
+ pairedProviderBlocks.add(p);
1632
1993
  }
1633
1994
 
1634
- const leftover = providerThinking.slice(matched);
1635
- if (leftover.length > 0 || redacted.length > 0) {
1636
- content.unshift(...leftover, ...redacted);
1995
+ for (const parsedIndex of parsedThinkingIndices().reverse()) {
1996
+ if (claimedParsedIndices.has(parsedIndex)) continue;
1997
+ const parsedText = (content[parsedIndex] as { thinking?: string }).thinking ?? '';
1998
+ if (parsedText === '') continue;
1999
+ const run = findSpanningProviderRun(providerThinking, pairedProviderBlocks, parsedText);
2000
+ if (!run) continue;
2001
+ content.splice(
2002
+ parsedIndex,
2003
+ 1,
2004
+ ...run.map((p) => {
2005
+ pairedProviderBlocks.add(p);
2006
+ const block = providerThinking[p]!;
2007
+ return {
2008
+ type: 'thinking',
2009
+ thinking: block.thinking ?? '',
2010
+ ...(block.signature ? { signature: block.signature } : {}),
2011
+ } as ContentBlock;
2012
+ })
2013
+ );
2014
+ claimedParsedIndices.add(parsedIndex);
2015
+ }
2016
+
2017
+ const seen = new Set(content.map((block) => thinkingCarrierKey(block)));
2018
+ const leftover: ContentBlock[] = [];
2019
+ for (let p = 0; p < providerThinking.length; p++) {
2020
+ if (pairedProviderBlocks.has(p)) continue;
2021
+ const block = providerThinking[p]! as unknown as ContentBlock;
2022
+ const key = thinkingCarrierKey(block);
2023
+ if (seen.has(key)) continue;
2024
+ seen.add(key);
2025
+ leftover.push(block);
1637
2026
  }
2027
+ for (const block of redacted) {
2028
+ const key = thinkingCarrierKey(block);
2029
+ if (seen.has(key)) continue;
2030
+ seen.add(key);
2031
+ leftover.push(block);
2032
+ }
2033
+
2034
+ if (leftover.length > 0) content.unshift(...leftover);
1638
2035
  }
1639
2036
 
1640
2037
  // ==========================================================================
@@ -1722,24 +2119,18 @@ export class Membrane {
1722
2119
  }
1723
2120
 
1724
2121
  /**
1725
- * Transform a normalized request into provider format using the formatter
2122
+ * Transform a normalized request into provider format using the formatter.
2123
+ *
2124
+ * `activeFormatter` is the instance the caller already selected via
2125
+ * resolveActiveFormatter — including that selection's Responses-transport
2126
+ * authority rule, which used to live inline here. It is a parameter and not
2127
+ * a re-derivation so that the formatter which BUILDS is the same one that
2128
+ * resolved the tool mode and drives the loop.
1726
2129
  */
1727
- private transformRequest(request: NormalizedRequest, formatter?: PrefillFormatter): {
2130
+ private transformRequest(request: NormalizedRequest, activeFormatter: PrefillFormatter = this.formatter): {
1728
2131
  providerRequest: any;
1729
2132
  prefillResult: BuildResult;
1730
2133
  } {
1731
- // The Responses adapter's input is a provider-native item array. A generic
1732
- // per-request formatter (for example Context Manager's NativeFormatter)
1733
- // produces Anthropic-style `{ role, content: [{ type: 'text' }] }`
1734
- // envelopes, which the Responses API rejects before inference. Keep the
1735
- // configured Responses formatter authoritative at this transport boundary;
1736
- // per-request formatter overrides remain available for adapters whose wire
1737
- // format supports them.
1738
- const activeFormatter =
1739
- this.adapter.name === 'openai-responses-api' && this.formatter.name === 'openai-responses'
1740
- ? this.formatter
1741
- : formatter ?? this.formatter;
1742
-
1743
2134
  // Extract user-provided stop sequences
1744
2135
  const additionalStopSequences = Array.isArray(request.stopSequences)
1745
2136
  ? request.stopSequences
@@ -1755,6 +2146,10 @@ export class Membrane {
1755
2146
  participantMode: 'multiuser',
1756
2147
  assistantParticipant: request.assistantParticipant ?? this.config.assistantParticipant ?? 'Claude',
1757
2148
  tools: request.tools,
2149
+ // One resolution for every entry point: complete() used to build from the
2150
+ // formatter's constructor-time mode alone, so request.toolMode was a
2151
+ // second, disconnected source of truth on this path.
2152
+ toolMode: this.resolveToolMode(request, activeFormatter),
1758
2153
  thinking: request.config.thinking,
1759
2154
  systemPrompt: request.system,
1760
2155
  promptCaching: request.promptCaching ?? this.config.defaultPromptCaching ?? true, // Default true for backward compat
@@ -1779,7 +2174,9 @@ export class Membrane {
1779
2174
  const providerRequest = {
1780
2175
  ...this.getBaseProviderParams(request.config),
1781
2176
  messages: buildResult.messages,
1782
- system: buildResult.systemContent,
2177
+ // Owned, not aliased: the wire clamp strips markers in place, and a
2178
+ // formatter may pass the caller's own system array straight through.
2179
+ system: ownSystemBlocks(buildResult.systemContent),
1783
2180
  stopSequences: buildResult.stopSequences,
1784
2181
  tools: buildResult.nativeTools,
1785
2182
  extra: {
@@ -1793,8 +2190,8 @@ export class Membrane {
1793
2190
  // literal `<thinking>` text prefix instead of the API feature — drop the
1794
2191
  // API param when the built request actually ends in an assistant prefill.
1795
2192
  // Chat-style builds (no prefill) keep it.
1796
- if (buildResult.assistantPrefill && providerRequest.thinking) {
1797
- delete providerRequest.thinking;
2193
+ if (buildResult.assistantPrefill) {
2194
+ stripThinkingForPrefill(providerRequest);
1798
2195
  }
1799
2196
 
1800
2197
  return { providerRequest, prefillResult: buildResult };
@@ -1836,24 +2233,72 @@ export class Membrane {
1836
2233
  * somewhere upstream.
1837
2234
  */
1838
2235
  onRetrying?: (info: { attempt: number; maxAttempts: number; category?: string }) => void;
2236
+ /**
2237
+ * Receives the number of cache_control markers the request ACTUALLY
2238
+ * ships with, taken from the clamp's own tally below — i.e. after the
2239
+ * `beforeRequest` hook has added or removed markers of its own and
2240
+ * after everything past the 4-breakpoint budget has been dropped.
2241
+ *
2242
+ * Telemetry that counts the request at BUILD time reports a number no
2243
+ * request ever had (a hook placing 7 markers on a wire that carries 4
2244
+ * was reported as the builder's 1), which defeats the audit the count
2245
+ * exists for. This is the only count that describes the wire.
2246
+ */
2247
+ onWireCacheMarkers?: (markerCount: number) => void;
1839
2248
  }
1840
- ) {
2249
+ ): Promise<
2250
+ import('./types/provider.js').ProviderResponse & {
2251
+ discardedUsage?: DiscardedAttemptsUsage;
2252
+ /** Provider calls this helper made, including refusal re-issues. */
2253
+ providerCalls: number;
2254
+ }
2255
+ > {
1841
2256
  // Strip `normalizedRequest` before forwarding to the adapter — it's
1842
2257
  // not part of `ProviderRequestOptions` and TypeScript's structural
1843
2258
  // compatibility won't catch the excess field (checked only on object
1844
2259
  // literals, not on variables). Leaving it in would silently leak the
1845
2260
  // normalized form into every adapter's options.
1846
- const { normalizedRequest, refusalRetries, onRetrying, ...adapterOptions } = options;
2261
+ const { normalizedRequest, refusalRetries, onRetrying, onWireCacheMarkers, ...adapterOptions } = options;
1847
2262
  const finalRequest = (await this.applyBeforeRequestHook(normalizedRequest, request)) as typeof request;
1848
2263
 
2264
+ // Last exit before the adapter: the only place that sees EVERY
2265
+ // contribution (builder, formatter, passthrough, float, hook). Every
2266
+ // streaming path — stream(), streamYielding(), both tool loops — funnels
2267
+ // through here, so this is the one clamp they all get, and its tally is
2268
+ // therefore the only count that describes the wire.
2269
+ const clampOutcome = clampCacheMarkers(finalRequest, 'streamOnce');
2270
+ onWireCacheMarkers?.(clampOutcome.total);
2271
+
1849
2272
  // Retries are only safe when the caller can discard the abandoned
1850
2273
  // attempt, so they require BOTH a budget and an onRetrying hook.
1851
2274
  const maxAttempts = onRetrying ? Math.max(0, refusalRetries ?? 0) : 0;
1852
2275
  let retried = 0;
2276
+ // Every re-issued attempt was a completed, billed provider call. The
2277
+ // caller's usage accumulator only ever sees the surviving result, so the
2278
+ // abandoned spend rides back out on the result itself.
2279
+ let discardedUsage: DiscardedAttemptsUsage | undefined;
2280
+ let providerCalls = 0;
1853
2281
  while (true) {
1854
- const result = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1855
- if (result.stopReason !== 'refusal' || retried >= maxAttempts) return result;
2282
+ providerCalls++;
2283
+ const rawResult = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
2284
+ // Restate usage in the one convention before any accumulator, ratio or
2285
+ // price sees it — this is the only door streamed usage enters through.
2286
+ const result: ProviderResponse = {
2287
+ ...rawResult,
2288
+ usage: normalizeUsageToCacheExcluded(rawResult.usage, this.adapter.name, this.adapter.usageCacheConvention),
2289
+ };
2290
+ if (result.stopReason !== 'refusal' || retried >= maxAttempts) {
2291
+ return {
2292
+ ...result,
2293
+ providerCalls,
2294
+ ...(discardedUsage ? { discardedUsage } : {}),
2295
+ };
2296
+ }
1856
2297
  retried++;
2298
+ discardedUsage = this.mergeDiscardedAttempts(
2299
+ discardedUsage,
2300
+ this.discardedAttemptFrom(result.usage)
2301
+ );
1857
2302
  const category = (result.raw as { response?: { stop_details?: { category?: string } } } | undefined)
1858
2303
  ?.response?.stop_details?.category;
1859
2304
  onRetrying!({ attempt: retried, maxAttempts, category });
@@ -1867,7 +2312,14 @@ export class Membrane {
1867
2312
  ): any {
1868
2313
  // Anthropic quirk: assistant content cannot end with trailing whitespace
1869
2314
  const trimmedAccumulated = accumulated.trimEnd();
1870
-
2315
+
2316
+ // Everything before the watermark already rides EARLIER messages (a
2317
+ // persisted split turn), so only the suffix belongs in the trailing
2318
+ // assistant prefill — replacing it with the whole document would
2319
+ // duplicate the pre-seam text and flatten the image user-turn away.
2320
+ const baseOffset = prefillResult.accumulatedBaseOffset ?? 0;
2321
+ const trailingContent = accumulated.slice(baseOffset).trimEnd();
2322
+
1871
2323
  // Build continuation messages: keep all messages up to last assistant,
1872
2324
  // then replace/add the accumulated content
1873
2325
  const messages = [...prefillResult.messages];
@@ -1876,34 +2328,34 @@ export class Membrane {
1876
2328
  let foundAssistant = false;
1877
2329
  for (let i = messages.length - 1; i >= 0; i--) {
1878
2330
  if (messages[i]?.role === 'assistant') {
1879
- messages[i] = { role: 'assistant', content: trimmedAccumulated };
2331
+ messages[i] = { role: 'assistant', content: trailingContent };
1880
2332
  foundAssistant = true;
1881
2333
  break;
1882
2334
  }
1883
2335
  }
1884
2336
 
1885
2337
  if (!foundAssistant) {
1886
- messages.push({ role: 'assistant', content: trimmedAccumulated });
2338
+ messages.push({ role: 'assistant', content: trailingContent });
1887
2339
  }
1888
2340
 
1889
- return {
2341
+ return stripThinkingForPrefill({
1890
2342
  ...this.getBaseProviderParams(originalRequest.config),
1891
2343
  // Continuations always end in an assistant prefill — the API rejects
1892
2344
  // extended thinking combined with prefill, so never send the param here
1893
2345
  thinking: undefined,
1894
2346
  messages,
1895
- system: prefillResult.systemContent
1896
- ? (Array.isArray(prefillResult.systemContent) && prefillResult.systemContent.length > 0
1897
- ? prefillResult.systemContent
1898
- : prefillResult.systemContent)
1899
- : undefined,
2347
+ system: ownSystemBlocks(prefillResult.systemContent) ?? undefined,
1900
2348
  stopSequences: prefillResult.stopSequences,
1901
2349
  extra: {
1902
2350
  ...originalRequest.providerParams,
2351
+ // Same contract transformRequest sends: adapters that reason about
2352
+ // the normalized shape (or fall back to serializing it) must not see
2353
+ // a continuation as a request with no normalized form at all.
2354
+ normalizedMessages: originalRequest.messages,
1903
2355
  // Pre-serialized prompt for completions adapters — skip re-serialization
1904
2356
  prompt: trimmedAccumulated,
1905
2357
  },
1906
- };
2358
+ });
1907
2359
  }
1908
2360
 
1909
2361
  /**
@@ -1933,6 +2385,12 @@ export class Membrane {
1933
2385
  // Anthropic quirk: assistant content cannot end with trailing whitespace
1934
2386
  const trimmedAccumulated = accumulated.trimEnd();
1935
2387
 
2388
+ // The split replaces only the CURRENT trailing assistant message, which
2389
+ // covers the accumulated text from the previous seam onward (0 on the
2390
+ // first split, the previous image seam on a later one).
2391
+ const baseOffset = prefillResult.accumulatedBaseOffset ?? 0;
2392
+ const trailingContent = accumulated.slice(baseOffset).trimEnd();
2393
+
1936
2394
  // Build messages: copy all, then replace only the last assistant with split-turn
1937
2395
  const messages: any[] = prefillResult.messages.map(msg => ({ ...msg }));
1938
2396
 
@@ -1948,7 +2406,7 @@ export class Membrane {
1948
2406
  // Anthropic quirk: assistant content cannot end with trailing whitespace
1949
2407
  const trimmedAfterXml = afterImageXml.trimEnd();
1950
2408
  const splitTurnMessages = [
1951
- { role: 'assistant', content: trimmedAccumulated },
2409
+ { role: 'assistant', content: trailingContent },
1952
2410
  { role: 'user', content: images },
1953
2411
  { role: 'assistant', content: trimmedAfterXml },
1954
2412
  ];
@@ -1959,20 +2417,40 @@ export class Membrane {
1959
2417
  messages.push(...splitTurnMessages);
1960
2418
  }
1961
2419
 
1962
- return {
2420
+ // PERSIST the split. Later rounds rebuild from prefillResult.messages;
2421
+ // without this the image user-turn exists on exactly one request and the
2422
+ // next continuation flattens the accumulated document back over it —
2423
+ // leaving <function_results> XML asserting a screenshot the model can no
2424
+ // longer see. Reassign (never mutate in place): the previous array is
2425
+ // still referenced by the request already on the wire. The watermark
2426
+ // moves to the seam — the point in `accumulated` where afterImageXml is
2427
+ // about to be appended — so the next builder replaces only the closing
2428
+ // assistant turn.
2429
+ prefillResult.messages = messages;
2430
+ prefillResult.accumulatedBaseOffset = accumulated.length;
2431
+
2432
+ return stripThinkingForPrefill({
1963
2433
  ...this.getBaseProviderParams(originalRequest.config),
1964
2434
  // Continuations always end in an assistant prefill — the API rejects
1965
2435
  // extended thinking combined with prefill, so never send the param here
1966
2436
  thinking: undefined,
1967
2437
  messages,
1968
- system: prefillResult.systemContent
1969
- ? (Array.isArray(prefillResult.systemContent) && prefillResult.systemContent.length > 0
1970
- ? prefillResult.systemContent
1971
- : prefillResult.systemContent)
1972
- : undefined,
2438
+ system: ownSystemBlocks(prefillResult.systemContent) ?? undefined,
1973
2439
  stopSequences: prefillResult.stopSequences,
1974
- extra: originalRequest.providerParams,
1975
- };
2440
+ // Copied, not aliased: the guard below deletes the smuggled thinking
2441
+ // config, and mutating the caller's own providerParams object would
2442
+ // silently disable thinking on their NEXT (non-prefill) request.
2443
+ extra: {
2444
+ ...originalRequest.providerParams,
2445
+ // Same contract as transformRequest and the plain continuation
2446
+ // builder. Without these a completions-style adapter fell through to
2447
+ // serializing PROVIDER-shaped messages as if they were normalized
2448
+ // ones, re-adding participant stop sequences the continuation
2449
+ // deliberately suppresses.
2450
+ normalizedMessages: originalRequest.messages,
2451
+ prompt: trimmedAccumulated,
2452
+ },
2453
+ });
1976
2454
  }
1977
2455
 
1978
2456
  private transformResponse(
@@ -2049,20 +2527,33 @@ export class Membrane {
2049
2527
 
2050
2528
  // Parse XML tool calls from text if no native tool_use blocks were found
2051
2529
  // This handles prefill mode where tools are XML in the text
2530
+ let emptyToolBlocks = 0;
2052
2531
  if (toolCalls.length === 0 && rawAssistantText.includes('<function_calls>')) {
2053
2532
  const parsed = parseToolCalls(rawAssistantText);
2054
2533
  if (parsed?.calls.length) {
2055
2534
  for (const tc of parsed.calls) {
2056
2535
  toolCalls.push(tc);
2057
2536
  }
2537
+ } else if (parsed) {
2538
+ emptyToolBlocks = 1;
2058
2539
  }
2059
2540
  }
2541
+ const unclosedToolBlock = endsWithPartialToolBlock(rawAssistantText);
2060
2542
 
2061
2543
  const stopReason = this.mapStopReason(providerResponse.stopReason);
2544
+ this.reportToolParseDiagnostics({ unclosedToolBlock, emptyToolBlocks }, stopReason);
2062
2545
  const durationMs = Date.now() - startTime;
2063
- const usage = {
2546
+ // `NormalizedResponse.usage` is typed DetailedUsage and the streaming paths
2547
+ // already return the whole thing; complete() used to narrow it to
2548
+ // input/output here, so a caller reading `response.usage.cacheReadTokens`
2549
+ // saw undefined on one path and a number on the other.
2550
+ const usage: DetailedUsage = {
2064
2551
  inputTokens: providerResponse.usage.inputTokens,
2065
2552
  outputTokens: providerResponse.usage.outputTokens,
2553
+ cacheCreationTokens: providerResponse.usage.cacheCreationTokens,
2554
+ cacheReadTokens: providerResponse.usage.cacheReadTokens,
2555
+ thinkingTokens: providerResponse.usage.thinkingTokens,
2556
+ estimatedCost: this.estimateCost(providerResponse.usage, request.config.model, providerResponse.model),
2066
2557
  };
2067
2558
 
2068
2559
  return {
@@ -2077,14 +2568,9 @@ export class Membrane {
2077
2568
  reason: stopReason,
2078
2569
  triggeredSequence: providerResponse.stopSequence,
2079
2570
  wasTruncated: stopReason === 'max_tokens',
2571
+ unclosedToolBlock,
2080
2572
  },
2081
- usage: {
2082
- inputTokens: providerResponse.usage.inputTokens,
2083
- outputTokens: providerResponse.usage.outputTokens,
2084
- cacheCreationTokens: providerResponse.usage.cacheCreationTokens,
2085
- cacheReadTokens: providerResponse.usage.cacheReadTokens,
2086
- estimatedCost: this.estimateCost(providerResponse.usage, request.config.model),
2087
- },
2573
+ usage,
2088
2574
  timing: {
2089
2575
  totalDurationMs: durationMs,
2090
2576
  attempts,
@@ -2108,11 +2594,65 @@ export class Membrane {
2108
2594
  };
2109
2595
  }
2110
2596
 
2597
+ /**
2598
+ * The turn is over, and the two guards that detect a half-written tool block
2599
+ * finally have a call site. Both shapes are defects a consumer must not
2600
+ * persist blind: an unclosed block splices onto the NEXT round's closing tag
2601
+ * (the loop does not resume on a length stop, so max_tokens leaves exactly
2602
+ * this), and a block that parsed to nothing means the model believes it
2603
+ * called a tool that never ran.
2604
+ */
2605
+ private reportToolParseDiagnostics(
2606
+ diagnostics: {
2607
+ unclosedToolBlock: boolean;
2608
+ emptyToolBlocks: number;
2609
+ splicedToolBlocks?: number;
2610
+ unclosedInvokeHeads?: number;
2611
+ },
2612
+ stopReason: StopReason
2613
+ ): void {
2614
+ const warnLog = this.config.logger ?? console;
2615
+
2616
+ if (diagnostics.unclosedToolBlock) {
2617
+ warnLog.warn(
2618
+ `[membrane] turn ended (${stopReason}) with an unclosed tool block in the ` +
2619
+ `assistant text — the loop does not resume on a length stop. Persisting this ` +
2620
+ `turn verbatim lets the next round's closing tag splice onto the stale ` +
2621
+ `opener; see details.stop.unclosedToolBlock.`
2622
+ );
2623
+ }
2624
+
2625
+ if (diagnostics.emptyToolBlocks > 0) {
2626
+ warnLog.warn(
2627
+ `[membrane] ${diagnostics.emptyToolBlocks} function_calls block(s) parsed to ` +
2628
+ `zero tool calls — always a defect, never a normal ending. The call was ` +
2629
+ `returned as assistant text and nothing executed.`
2630
+ );
2631
+ }
2632
+
2633
+ if (diagnostics.splicedToolBlocks) {
2634
+ warnLog.warn(
2635
+ `[membrane] ${diagnostics.splicedToolBlocks} tool block(s) spanned a second ` +
2636
+ `<function_calls> opener and were re-anchored to the innermost one — an ` +
2637
+ `earlier truncated block is present in this conversation's assistant text.`
2638
+ );
2639
+ }
2640
+
2641
+ if (diagnostics.unclosedInvokeHeads) {
2642
+ warnLog.warn(
2643
+ `[membrane] ${diagnostics.unclosedInvokeHeads} <invoke> head(s) were left ` +
2644
+ `unclosed and swallowed the invoke that followed — nothing was dispatched ` +
2645
+ `under an unclosed head's name, and the call it absorbed was re-anchored ` +
2646
+ `and ran with its own parameters.`
2647
+ );
2648
+ }
2649
+ }
2650
+
2111
2651
  private buildFinalResponse(
2112
2652
  accumulated: string,
2113
2653
  contentBlocks: ContentBlock[],
2114
2654
  stopReason: StopReason,
2115
- usage: DetailedUsage,
2655
+ turnUsage: TurnUsageAccumulator,
2116
2656
  request: NormalizedRequest,
2117
2657
  prefillResult: {
2118
2658
  cacheMarkersApplied?: number;
@@ -2124,14 +2664,17 @@ export class Membrane {
2124
2664
  executedToolCalls: ToolCall[] = [],
2125
2665
  executedToolResults: ToolResult[] = [],
2126
2666
  startInsideBlock: 'thinking' | 'tool_call' | 'tool_result' | null = null,
2127
- triggeredSequence?: string
2667
+ triggeredSequence?: string,
2128
2668
  ): NormalizedResponse {
2669
+ const usage = turnUsage.total;
2129
2670
  // Parse accumulated text into structured content blocks
2130
2671
  // This extracts thinking, tool_use, tool_result, and text blocks
2131
2672
  let finalContent: ContentBlock[];
2132
2673
  let toolCalls: ToolCall[];
2133
2674
  let toolResults: ToolResult[];
2134
2675
 
2676
+ let unclosedToolBlock = false;
2677
+
2135
2678
  if (contentBlocks.length > 0) {
2136
2679
  // Native mode - content blocks already structured
2137
2680
  finalContent = contentBlocks;
@@ -2146,6 +2689,8 @@ export class Membrane {
2146
2689
  finalContent = parsed.blocks;
2147
2690
  toolCalls = parsed.toolCalls.length > 0 ? parsed.toolCalls : executedToolCalls;
2148
2691
  toolResults = parsed.toolResults.length > 0 ? parsed.toolResults : executedToolResults;
2692
+ unclosedToolBlock = parsed.unclosedToolBlock;
2693
+ this.reportToolParseDiagnostics(parsed, stopReason);
2149
2694
  }
2150
2695
 
2151
2696
  const durationMs = Date.now() - startTime;
@@ -2162,19 +2707,21 @@ export class Membrane {
2162
2707
  reason: stopReason,
2163
2708
  triggeredSequence,
2164
2709
  wasTruncated: stopReason === 'max_tokens',
2710
+ unclosedToolBlock,
2165
2711
  },
2166
- usage: {
2167
- ...usage,
2168
- estimatedCost: usage.estimatedCost ?? this.estimateCost(usage, request.config.model),
2169
- },
2712
+ // Priced per round by the accumulator and summed — NOT re-derived here
2713
+ // from the turn totals, which would re-bill every round at the last
2714
+ // model's rate.
2715
+ usage,
2170
2716
  timing: {
2171
2717
  totalDurationMs: durationMs,
2172
2718
  attempts,
2173
2719
  },
2174
2720
  model: {
2175
2721
  requested: request.config.model,
2176
- actual: request.config.model, // TODO: get from response
2722
+ actual: turnUsage.lastServedModel || request.config.model,
2177
2723
  provider: this.adapter.name,
2724
+ perRound: turnUsage.perRound,
2178
2725
  },
2179
2726
  cache: {
2180
2727
  markersInRequest: prefillResult.cacheMarkersApplied ?? 0,
@@ -2190,6 +2737,52 @@ export class Membrane {
2190
2737
  };
2191
2738
  }
2192
2739
 
2740
+ /**
2741
+ * Fold one discarded (billed but abandoned) attempt's usage into a carry.
2742
+ * Returns a NEW object so a caller's earlier snapshot is never mutated.
2743
+ */
2744
+ private mergeDiscardedAttempts(
2745
+ carry: DiscardedAttemptsUsage | undefined,
2746
+ add: DiscardedAttemptsUsage | undefined
2747
+ ): DiscardedAttemptsUsage | undefined {
2748
+ if (!add) return carry;
2749
+ const next: DiscardedAttemptsUsage = carry
2750
+ ? { ...carry }
2751
+ : { attempts: 0, inputTokens: 0, outputTokens: 0 };
2752
+ next.attempts += add.attempts;
2753
+ next.inputTokens += add.inputTokens;
2754
+ next.outputTokens += add.outputTokens;
2755
+ if (add.cacheCreationTokens) {
2756
+ next.cacheCreationTokens = (next.cacheCreationTokens ?? 0) + add.cacheCreationTokens;
2757
+ }
2758
+ if (add.cacheReadTokens) {
2759
+ next.cacheReadTokens = (next.cacheReadTokens ?? 0) + add.cacheReadTokens;
2760
+ }
2761
+ return next;
2762
+ }
2763
+
2764
+ /** One provider call's usage as a single-attempt discard record. */
2765
+ private discardedAttemptFrom(usage: DetailedUsage | BasicUsage | undefined): DiscardedAttemptsUsage {
2766
+ const detailed = (usage ?? { inputTokens: 0, outputTokens: 0 }) as DetailedUsage;
2767
+ return {
2768
+ attempts: 1,
2769
+ inputTokens: detailed.inputTokens ?? 0,
2770
+ outputTokens: detailed.outputTokens ?? 0,
2771
+ ...(detailed.cacheCreationTokens ? { cacheCreationTokens: detailed.cacheCreationTokens } : {}),
2772
+ ...(detailed.cacheReadTokens ? { cacheReadTokens: detailed.cacheReadTokens } : {}),
2773
+ };
2774
+ }
2775
+
2776
+ /** Price the discarded spend so a caller can read it without re-deriving. */
2777
+ private pricedDiscardedAttempts(
2778
+ discarded: DiscardedAttemptsUsage | undefined,
2779
+ model: string
2780
+ ): DiscardedAttemptsUsage | undefined {
2781
+ if (!discarded) return undefined;
2782
+ const estimatedCost = this.estimateCost(discarded, model);
2783
+ return estimatedCost ? { ...discarded, estimatedCost } : discarded;
2784
+ }
2785
+
2193
2786
  private mapStopReason(providerReason: string): StopReason {
2194
2787
  switch (providerReason) {
2195
2788
  case 'end_turn':
@@ -2211,20 +2804,62 @@ export class Membrane {
2211
2804
  }
2212
2805
 
2213
2806
  private calculateCacheHitRatio(usage: Pick<DetailedUsage, 'inputTokens' | 'cacheReadTokens'>): number {
2214
- const cacheRead = usage.cacheReadTokens ?? 0;
2215
- const total = usage.inputTokens ?? 0;
2216
- if (total === 0) return 0;
2217
- return cacheRead / total;
2807
+ return calculateCacheHitRatio(usage);
2218
2808
  }
2219
2809
 
2220
- private resolvePricing(model: string): import('./types/provider.js').ModelPricing | undefined {
2221
- return this.registry?.getPricing(model) ?? getDefaultPricing(model);
2810
+ /**
2811
+ * Pricing is resolved on TWO axes, and SOURCE outranks SPECIFICITY:
2812
+ *
2813
+ * registry[served] → registry[requested] → builtin[served] → builtin[requested]
2814
+ *
2815
+ * Specificity — preferring the model that ACTUALLY served over the id that
2816
+ * was requested — is real: an alias or an auto-routed request otherwise
2817
+ * prices against a string the provider already replaced, and a live
2818
+ * 2026-08-25 call asking for `gpt-4o-mini` was served by
2819
+ * `gpt-4o-mini-2024-07-18`. But it only breaks ties WITHIN one source.
2820
+ * A configured `ModelRegistry` is the caller stating their own rates —
2821
+ * account-specific, negotiated, authoritative; the built-in table is
2822
+ * membrane's shipped guess at public list prices. Merging the two per-model
2823
+ * (`registry[served] ?? builtin[served]`, return on the first hit) let the
2824
+ * guess for a snapshot outrank the caller's own entry for the alias they
2825
+ * asked for, so a caller who prices their alias and lets the provider pick
2826
+ * the snapshot was billed at membrane's number instead of theirs.
2827
+ *
2828
+ * Both fallbacks stay: the served model may be absent from a source, and the
2829
+ * provider may name none at all.
2830
+ */
2831
+ private resolvePricing(
2832
+ requestedModel: string,
2833
+ actualModel?: string
2834
+ ): import('./types/provider.js').ModelPricing | undefined {
2835
+ const servedModel = actualModel && actualModel !== requestedModel ? actualModel : undefined;
2836
+ const fromRegistry = (modelId: string | undefined) =>
2837
+ modelId === undefined ? undefined : this.registry?.getPricing(modelId);
2838
+ const fromBuiltin = (modelId: string | undefined) =>
2839
+ modelId === undefined ? undefined : getDefaultPricing(modelId);
2840
+
2841
+ return fromRegistry(servedModel)
2842
+ ?? fromRegistry(requestedModel)
2843
+ ?? fromBuiltin(servedModel)
2844
+ ?? fromBuiltin(requestedModel);
2222
2845
  }
2223
2846
 
2224
2847
  /** Resolve pricing + calculate cost in one call (for one-shot use outside loops). */
2225
- private estimateCost(usage: import('./utils/cost.js').CostableUsage, model: string): import('./types/response.js').CostBreakdown | undefined {
2226
- const pricing = this.resolvePricing(model);
2227
- return pricing ? calculateCost(usage, pricing) : undefined;
2848
+ private estimateCost(
2849
+ usage: import('./utils/cost.js').CostableUsage,
2850
+ requestedModel: string,
2851
+ actualModel?: string
2852
+ ): import('./types/response.js').CostBreakdown | undefined {
2853
+ const pricing = this.resolvePricing(requestedModel, actualModel);
2854
+ if (!pricing) {
2855
+ // An absent cost and a zero cost are different claims. Returning
2856
+ // undefined says "membrane does not know what this costs"; saying it out
2857
+ // loud once per model keeps that from reading as "free" to a caller that
2858
+ // only ever sees the omission.
2859
+ warnUnpricedModel(actualModel || requestedModel);
2860
+ return undefined;
2861
+ }
2862
+ return calculateCost(usage, pricing);
2228
2863
  }
2229
2864
 
2230
2865
  private calculateRetryDelay(attempt: number, overloaded = false): number {
@@ -2262,6 +2897,10 @@ export class Membrane {
2262
2897
  * Check if an error is an abort error
2263
2898
  */
2264
2899
  private isAbortError(error: unknown): boolean {
2900
+ // An adapter's own deadline: a timeout by classification, still an abort
2901
+ // by provenance, so the streaming paths hand back the partial content
2902
+ // they collected instead of throwing.
2903
+ if (isTimeoutAbortError(error)) return true;
2265
2904
  if (error instanceof Error) {
2266
2905
  // Standard AbortError
2267
2906
  if (error.name === 'AbortError') return true;
@@ -2275,6 +2914,20 @@ export class Membrane {
2275
2914
  return false;
2276
2915
  }
2277
2916
 
2917
+ /**
2918
+ * Why a caught abort happened. The caller's own signal is authoritative:
2919
+ * if it fired, the cancellation is theirs whatever the error text says.
2920
+ * Otherwise an adapter-side deadline classifies as a timeout — the adapters
2921
+ * mark the abort createCombinedSignal's timeoutMs raises and map it to a
2922
+ * TimeoutAbortError, so the identity survives their error handling — and
2923
+ * anything else that reached the abort catch is a failure, not a person.
2924
+ */
2925
+ private abortReason(error: unknown, signal?: AbortSignal): 'user' | 'timeout' | 'error' {
2926
+ if (signal?.aborted) return 'user';
2927
+ if (classifyError(error).type === 'timeout') return 'timeout';
2928
+ return 'error';
2929
+ }
2930
+
2278
2931
  /**
2279
2932
  * Build an AbortedResponse from current execution state
2280
2933
  */
@@ -2337,7 +2990,12 @@ export class Membrane {
2337
2990
  request: NormalizedRequest,
2338
2991
  options: YieldingStreamOptions = {}
2339
2992
  ): YieldingStream {
2340
- const toolMode = this.resolveToolMode(request);
2993
+ // YieldingStreamOptions carries no per-request formatter override, so the
2994
+ // selection here can only land on the instance formatter — it goes through
2995
+ // resolveActiveFormatter anyway so this path reads the same single source
2996
+ // as complete() and stream() if an override is ever added.
2997
+ const activeFormatter = this.resolveActiveFormatter();
2998
+ const toolMode = this.resolveToolMode(request, activeFormatter);
2341
2999
 
2342
3000
  // refusalRetries is implemented on the native path only. The XML path
2343
3001
  // accumulates into a streaming parser carrying prefill context and
@@ -2353,8 +3011,8 @@ export class Membrane {
2353
3011
 
2354
3012
  // Create the yielding stream with the appropriate inference runner
2355
3013
  const runInference = toolMode === 'native'
2356
- ? (stream: YieldingStreamImpl) => this.runNativeToolsYielding(request, options, stream)
2357
- : (stream: YieldingStreamImpl) => this.runXmlToolsYielding(request, options, stream);
3014
+ ? (stream: YieldingStreamImpl) => this.runNativeToolsYielding(request, options, stream, activeFormatter)
3015
+ : (stream: YieldingStreamImpl) => this.runXmlToolsYielding(request, options, stream, activeFormatter);
2358
3016
 
2359
3017
  return new YieldingStreamImpl(options, runInference);
2360
3018
  }
@@ -2365,7 +3023,8 @@ export class Membrane {
2365
3023
  private async runXmlToolsYielding(
2366
3024
  request: NormalizedRequest,
2367
3025
  options: YieldingStreamOptions,
2368
- stream: YieldingStreamImpl
3026
+ stream: YieldingStreamImpl,
3027
+ activeFormatter: PrefillFormatter = this.resolveActiveFormatter()
2369
3028
  ): Promise<void> {
2370
3029
  const startTime = Date.now();
2371
3030
  const {
@@ -2407,14 +3066,23 @@ export class Membrane {
2407
3066
  let prevRoundStopSequence: string | undefined;
2408
3067
  const warnLog = this.config.logger ?? console;
2409
3068
 
2410
- // Initialize parser from formatter for format-specific tracking
2411
- const formatter = this.formatter;
3069
+ // Initialize parser from the formatter streamYielding selected, so the
3070
+ // parser and the build below read the same format.
3071
+ const formatter = activeFormatter;
2412
3072
  const parser = formatter.createStreamParser();
2413
3073
  let toolDepth = 0;
3074
+ // Honest turn telemetry: provider calls actually made (including refusal
3075
+ // re-issues inside streamOnce) and continuation rounds.
3076
+ let providerCalls = 0;
3077
+ let rounds = 0;
2414
3078
  // Once-per-stream latch for the injectedMessages-unsupported warning.
2415
3079
  let warnedInjectionUnsupported = false;
2416
- let totalUsage: DetailedUsage = { inputTokens: 0, outputTokens: 0 };
2417
- const pricing = this.resolvePricing(request.config.model);
3080
+ // See streamWithXmlTools: one accumulator per turn, pricing each round
3081
+ // under the model that served it.
3082
+ const turnUsage = new TurnUsageAccumulator(
3083
+ request.config.model,
3084
+ (servedModel) => this.resolvePricing(request.config.model, servedModel),
3085
+ );
2418
3086
  const contentBlocks: ContentBlock[] = [];
2419
3087
  let lastStopReason: StopReason = 'end_turn';
2420
3088
  let lastStopSequence: string | undefined;
@@ -2463,7 +3131,7 @@ export class Membrane {
2463
3131
  if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
2464
3132
  warnLog.warn(
2465
3133
  `[membrane] automatic resumption at round ${resumptionRounds} ` +
2466
- `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
3134
+ `(${turnUsage.total.inputTokens} input tokens so far this turn) — ` +
2467
3135
  `a spin shows up here before it shows up on the bill`
2468
3136
  );
2469
3137
  }
@@ -2471,7 +3139,7 @@ export class Membrane {
2471
3139
  warnLog.warn(
2472
3140
  `[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
2473
3141
  `ending turn with stopReason 'round_limit'. ` +
2474
- `${totalUsage.inputTokens} input tokens spent this turn.`
3142
+ `${turnUsage.total.inputTokens} input tokens spent this turn.`
2475
3143
  );
2476
3144
  return false;
2477
3145
  }
@@ -2567,6 +3235,9 @@ export class Membrane {
2567
3235
  }
2568
3236
  );
2569
3237
 
3238
+ rounds++;
3239
+ providerCalls += streamResult.providerCalls;
3240
+
2570
3241
  // If we detected stop sequence manually, fix up the parser and result
2571
3242
  if (detectedStopSequence && truncatedAccumulated !== null) {
2572
3243
  parser.reset();
@@ -2584,18 +3255,10 @@ export class Membrane {
2584
3255
  lastStopReason = this.mapStopReason(streamResult.stopReason);
2585
3256
  lastStopSequence = streamResult.stopSequence ?? undefined;
2586
3257
 
2587
- // Accumulate usage (including cache metrics)
2588
- totalUsage.inputTokens += streamResult.usage.inputTokens;
2589
- totalUsage.outputTokens += streamResult.usage.outputTokens;
2590
- if (streamResult.usage.cacheCreationTokens) {
2591
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
2592
- }
2593
- if (streamResult.usage.cacheReadTokens) {
2594
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
2595
- }
2596
- if (pricing) totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
3258
+ // Accumulate usage (including cache metrics), priced at this round's model
3259
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
2597
3260
  if (emitUsage) {
2598
- stream.emit({ type: 'usage', usage: { ...totalUsage } });
3261
+ stream.emit({ type: 'usage', usage: usageSoFar });
2599
3262
  }
2600
3263
 
2601
3264
  // Flush the parser
@@ -2623,7 +3286,7 @@ export class Membrane {
2623
3286
  `[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
2624
3287
  `made no progress (${streamedThisRound} chars this round, stop ` +
2625
3288
  `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
2626
- `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
3289
+ `stopReason 'no_progress'. ${turnUsage.total.inputTokens} input tokens spent this turn.`
2627
3290
  );
2628
3291
  lastStopReason = 'no_progress';
2629
3292
  break;
@@ -2924,22 +3587,24 @@ export class Membrane {
2924
3587
  newContent,
2925
3588
  contentBlocks,
2926
3589
  lastStopReason,
2927
- totalUsage,
3590
+ turnUsage,
2928
3591
  request,
2929
3592
  prefillResult,
2930
3593
  startTime,
2931
- 1,
3594
+ providerCalls,
2932
3595
  rawRequest,
2933
3596
  rawResponse,
2934
3597
  executedToolCalls,
2935
3598
  executedToolResults,
2936
3599
  initialBlockType,
2937
- lastStopSequence
3600
+ lastStopSequence,
2938
3601
  );
2939
3602
 
2940
3603
  // Merge provider thinking signatures into parser-derived thinking blocks
2941
3604
  this.mergeProviderThinkingBlocks(response.content, providerThinkingBlocks);
2942
3605
 
3606
+ response.details.timing.rounds = rounds;
3607
+
2943
3608
  stream.emit({ type: 'complete', response });
2944
3609
  } catch (error) {
2945
3610
  if (this.isAbortError(error)) {
@@ -2947,7 +3612,7 @@ export class Membrane {
2947
3612
  const newContent = fullAccumulated.slice(initialPrefillLength);
2948
3613
  stream.emit({
2949
3614
  type: 'aborted',
2950
- reason: 'user',
3615
+ reason: this.abortReason(error, stream.signal),
2951
3616
  partialContent: parseAccumulatedIntoBlocks(newContent).blocks,
2952
3617
  rawAssistantText: newContent,
2953
3618
  toolCalls: executedToolCalls,
@@ -2965,7 +3630,8 @@ export class Membrane {
2965
3630
  private async runNativeToolsYielding(
2966
3631
  request: NormalizedRequest,
2967
3632
  options: YieldingStreamOptions,
2968
- stream: YieldingStreamImpl
3633
+ stream: YieldingStreamImpl,
3634
+ activeFormatter: PrefillFormatter = this.resolveActiveFormatter()
2969
3635
  ): Promise<void> {
2970
3636
  const startTime = Date.now();
2971
3637
  const {
@@ -2985,8 +3651,16 @@ export class Membrane {
2985
3651
  : maxToolDepthOpt;
2986
3652
 
2987
3653
  let toolDepth = 0;
2988
- let totalUsage: DetailedUsage = { inputTokens: 0, outputTokens: 0 };
2989
- const pricing = this.resolvePricing(request.config.model);
3654
+ // See streamWithXmlTools: one accumulator per turn, pricing each round
3655
+ // under the model that served it.
3656
+ const turnUsage = new TurnUsageAccumulator(
3657
+ request.config.model,
3658
+ (servedModel) => this.resolvePricing(request.config.model, servedModel),
3659
+ );
3660
+ // Honest turn telemetry: provider calls actually made (including refusal
3661
+ // re-issues inside streamOnce) and continuation rounds.
3662
+ let providerCalls = 0;
3663
+ let rounds = 0;
2990
3664
  let lastStopReason: StopReason = 'end_turn';
2991
3665
  let lastStopSequence: string | undefined;
2992
3666
  let rawRequest: unknown;
@@ -2995,9 +3669,12 @@ export class Membrane {
2995
3669
  let allTextAccumulated = '';
2996
3670
  const executedToolCalls: ToolCall[] = [];
2997
3671
  const executedToolResults: ToolResult[] = [];
3672
+ // Spend on refusal attempts this turn threw away (see streamOnce).
3673
+ let discardedUsage: DiscardedAttemptsUsage | undefined;
2998
3674
 
2999
3675
  let messages = [...request.messages];
3000
3676
  let allContentBlocks: ContentBlock[] = [];
3677
+ let markersInLastRequest = 0;
3001
3678
 
3002
3679
  try {
3003
3680
  // Tool execution loop
@@ -3015,25 +3692,20 @@ export class Membrane {
3015
3692
  }
3016
3693
 
3017
3694
  // Build provider request with native tools
3018
- const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0);
3695
+ const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0, activeFormatter);
3019
3696
 
3020
3697
  // Stream from provider
3021
3698
  let textAccumulated = '';
3022
- let blockIndex = 0;
3023
3699
  // Where this attempt starts inside the tool-loop-spanning buffer, so
3024
3700
  // a refusal retry can roll back exactly this attempt's contribution.
3025
3701
  const allTextBefore = allTextAccumulated.length;
3026
- // Track block-type from the provider's content_block_start signal so
3702
+ // Track block-type from the provider's content_block signals so
3027
3703
  // every token chunk is tagged with the membrane block it belongs to.
3028
3704
  // Without this, thinking_delta chunks get mislabelled as 'text' and
3029
3705
  // downstream consumers (TUIs, WebUIs) can't render them distinctly.
3030
- let currentBlockType: MembraneBlockType = 'text';
3031
- const seenBlockIndices = new Set<number>();
3032
- const mapApiBlockType = (apiType: string | undefined): MembraneBlockType => {
3033
- if (apiType === 'thinking') return 'thinking';
3034
- if (apiType === 'tool_use') return 'tool_call';
3035
- return 'text';
3036
- };
3706
+ const tracker = new NativeBlockTracker(
3707
+ emitBlocks ? (event) => stream.emit({ type: 'block', event }) : undefined,
3708
+ );
3037
3709
  const streamResult = await this.streamOnce(
3038
3710
  providerRequest,
3039
3711
  {
@@ -3045,54 +3717,16 @@ export class Membrane {
3045
3717
 
3046
3718
  if (emitTokens) {
3047
3719
  const meta: ChunkMeta = {
3048
- type: currentBlockType,
3049
- visible: currentBlockType === 'text',
3050
- blockIndex,
3720
+ type: tracker.currentType,
3721
+ visible: tracker.currentType === 'text',
3722
+ blockIndex: tracker.blockIndex,
3051
3723
  };
3052
3724
  stream.emit({ type: 'tokens', content: chunk, meta });
3053
3725
  }
3054
3726
  },
3055
3727
  onContentBlock: (index, block) => {
3056
3728
  if (stream.isCancelled) return;
3057
- const apiType = (block as { type?: string } | undefined)?.type;
3058
- const mbType = mapApiBlockType(apiType);
3059
- const isStart = !seenBlockIndices.has(index);
3060
- if (isStart) {
3061
- seenBlockIndices.add(index);
3062
- currentBlockType = mbType;
3063
- blockIndex = index;
3064
- if (emitBlocks) {
3065
- stream.emit({
3066
- type: 'block',
3067
- event: { event: 'block_start', index, block: { type: mbType } },
3068
- });
3069
- }
3070
- } else if (emitBlocks) {
3071
- // Second call for the same index = content_block_stop. The
3072
- // provider has filled the block with final content; surface
3073
- // a block_complete with the relevant fields for consumers
3074
- // that want full block payloads (e.g. context-manager).
3075
- const apiBlock = block as {
3076
- type?: string;
3077
- text?: string;
3078
- thinking?: string;
3079
- id?: string;
3080
- name?: string;
3081
- input?: unknown;
3082
- } | undefined;
3083
- const mb: MembraneBlock = { type: mbType };
3084
- if (mbType === 'text') mb.content = apiBlock?.text;
3085
- else if (mbType === 'thinking') mb.content = apiBlock?.thinking;
3086
- else if (mbType === 'tool_call') {
3087
- mb.toolId = apiBlock?.id;
3088
- mb.toolName = apiBlock?.name;
3089
- mb.input = apiBlock?.input as Record<string, unknown> | undefined;
3090
- }
3091
- stream.emit({
3092
- type: 'block',
3093
- event: { event: 'block_complete', index, block: mb },
3094
- });
3095
- }
3729
+ tracker.onProviderBlock(index, block);
3096
3730
  },
3097
3731
  },
3098
3732
  {
@@ -3101,6 +3735,14 @@ export class Membrane {
3101
3735
  idleTimeoutMs: options.idleTimeoutMs,
3102
3736
  normalizedRequest: request,
3103
3737
  onRequest: (req: unknown) => { rawRequest = req; },
3738
+ // Telemetry reports what this request actually SHIPPED with —
3739
+ // builder breakpoints, stale passthrough, fallback, float, plus
3740
+ // whatever the beforeRequest hook and the wire clamp did after
3741
+ // the build. Both native paths used to hardcode 0, and counting
3742
+ // at build time reported a number no request ever had.
3743
+ onWireCacheMarkers: (markerCount: number) => {
3744
+ markersInLastRequest = markerCount;
3745
+ },
3104
3746
  refusalRetries: options.refusalRetries,
3105
3747
  // Discard the refused attempt: roll the accumulators back to
3106
3748
  // where this attempt began and tell the consumer to drop what it
@@ -3109,9 +3751,7 @@ export class Membrane {
3109
3751
  onRetrying: (info) => {
3110
3752
  allTextAccumulated = allTextAccumulated.slice(0, allTextBefore);
3111
3753
  textAccumulated = '';
3112
- blockIndex = 0;
3113
- currentBlockType = 'text';
3114
- seenBlockIndices.clear();
3754
+ tracker.reset();
3115
3755
  stream.emit({
3116
3756
  type: 'retrying',
3117
3757
  attempt: info.attempt,
@@ -3123,22 +3763,24 @@ export class Membrane {
3123
3763
  }
3124
3764
  );
3125
3765
 
3766
+ // Single-callback adapters (OpenAI Responses) report each finalised
3767
+ // block once, after the stream: complete whatever never saw a stop.
3768
+ tracker.flush();
3769
+ rounds++;
3770
+ providerCalls += streamResult.providerCalls;
3771
+
3126
3772
  rawResponse = streamResult.raw;
3127
3773
  lastStopReason = this.mapStopReason(streamResult.stopReason);
3128
3774
  lastStopSequence = streamResult.stopSequence ?? undefined;
3129
3775
 
3130
- // Accumulate usage (including cache metrics)
3131
- totalUsage.inputTokens += streamResult.usage.inputTokens;
3132
- totalUsage.outputTokens += streamResult.usage.outputTokens;
3133
- if (streamResult.usage.cacheCreationTokens) {
3134
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
3135
- }
3136
- if (streamResult.usage.cacheReadTokens) {
3137
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
3138
- }
3139
- if (pricing) totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
3776
+ // Attempts this round re-issued past a refusal are billed calls whose
3777
+ // output was discarded — carry their spend to the final response.
3778
+ discardedUsage = this.mergeDiscardedAttempts(discardedUsage, streamResult.discardedUsage);
3779
+
3780
+ // Accumulate usage (including cache metrics), priced at this round's model
3781
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
3140
3782
  if (emitUsage) {
3141
- stream.emit({ type: 'usage', usage: { ...totalUsage } });
3783
+ stream.emit({ type: 'usage', usage: usageSoFar });
3142
3784
  }
3143
3785
 
3144
3786
  // Parse content blocks from response
@@ -3241,6 +3883,7 @@ export class Membrane {
3241
3883
  }
3242
3884
 
3243
3885
  const durationMs = Date.now() - startTime;
3886
+ const totalUsage = turnUsage.total;
3244
3887
 
3245
3888
  const response: NormalizedResponse = {
3246
3889
  content: allContentBlocks,
@@ -3255,18 +3898,25 @@ export class Membrane {
3255
3898
  triggeredSequence: lastStopSequence,
3256
3899
  wasTruncated: lastStopReason === 'max_tokens',
3257
3900
  },
3258
- usage: { ...totalUsage },
3901
+ usage: {
3902
+ ...totalUsage,
3903
+ ...(discardedUsage
3904
+ ? { discardedAttempts: this.pricedDiscardedAttempts(discardedUsage, request.config.model) }
3905
+ : {}),
3906
+ },
3259
3907
  timing: {
3260
3908
  totalDurationMs: durationMs,
3261
- attempts: 1,
3909
+ attempts: providerCalls,
3910
+ rounds,
3262
3911
  },
3263
3912
  model: {
3264
3913
  requested: request.config.model,
3265
- actual: request.config.model,
3914
+ actual: turnUsage.lastServedModel || request.config.model,
3266
3915
  provider: this.adapter.name,
3916
+ perRound: turnUsage.perRound,
3267
3917
  },
3268
3918
  cache: {
3269
- markersInRequest: 0,
3919
+ markersInRequest: markersInLastRequest,
3270
3920
  tokensCreated: totalUsage.cacheCreationTokens ?? 0,
3271
3921
  tokensRead: totalUsage.cacheReadTokens ?? 0,
3272
3922
  hitRatio: this.calculateCacheHitRatio(totalUsage),
@@ -3283,7 +3933,7 @@ export class Membrane {
3283
3933
  if (this.isAbortError(error)) {
3284
3934
  stream.emit({
3285
3935
  type: 'aborted',
3286
- reason: 'user',
3936
+ reason: this.abortReason(error, stream.signal),
3287
3937
  rawAssistantText: allTextAccumulated,
3288
3938
  toolCalls: executedToolCalls,
3289
3939
  toolResults: executedToolResults,