@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/dist/membrane.js CHANGED
@@ -4,17 +4,104 @@
4
4
  * A selective boundary that transforms what passes through.
5
5
  */
6
6
  import { lastCacheableBlockIndex } from './formatters/native.js';
7
- import { DEFAULT_RETRY_CONFIG, MembraneError, classifyError, isOverloadedError, } from './types/index.js';
8
- import { parseToolCalls, formatToolResults, parseAccumulatedIntoBlocks, hasImageInToolResults, formatToolResultsForSplitTurn, } from './utils/tool-parser.js';
7
+ import { sameThinkingText, findSpanningProviderRun, thinkingCarrierKey, stripThinkingForPrefill, } from './utils/thinking-carriers.js';
8
+ import { countWireCacheMarkers, clampCacheMarkers, ownSystemBlocks, MAX_CACHE_BREAKPOINTS, } from './utils/cache-marker-budget.js';
9
+ import { DEFAULT_RETRY_CONFIG, MembraneError, classifyError, isOverloadedError, isTimeoutAbortError, unsupportedError, } from './types/index.js';
10
+ import { parseToolCalls, formatToolResults, parseAccumulatedIntoBlocks, endsWithPartialToolBlock, hasImageInToolResults, formatToolResultsForSplitTurn, } from './utils/tool-parser.js';
9
11
  import { AnthropicXmlFormatter } from './formatters/anthropic-xml.js';
10
- import { normalizeToolPairs, mergeConsecutiveRoles } from './formatters/normalize-tool-pairs.js';
12
+ import { normalizeToolPairs, mergeConsecutiveRoles, PREFIX_REWRITING_NORMALIZE_EVENT_KINDS, } from './formatters/normalize-tool-pairs.js';
11
13
  import { YieldingStreamImpl } from './yielding-stream.js';
12
- import { calculateCost } from './utils/cost.js';
14
+ import { calculateCost, warnUnpricedModel } from './utils/cost.js';
15
+ import { TurnUsageAccumulator, calculateCacheHitRatio, normalizeUsageToCacheExcluded, warnUnconvertibleProviderItem, } from './utils/usage.js';
13
16
  import { isAcceptedImageMediaType, strippedImagePlaceholder, shedImagesToFitByteBudget, assertWithinByteBudget, } from './utils/image-media.js';
14
17
  import { getDefaultPricing } from './registry/default-pricing.js';
15
18
  // ============================================================================
16
19
  // Membrane Class
17
20
  // ============================================================================
21
+ /**
22
+ * Block-lifecycle tracking shared by the two native-tools streaming paths
23
+ * (`streamWithNativeTools` and `runNativeToolsYielding`).
24
+ *
25
+ * Providers signal blocks through `onContentBlock(index, block)`, but not all
26
+ * of them the same way: the Anthropic and Bedrock adapters fire it twice per
27
+ * index (content_block_start with an empty block, content_block_stop with the
28
+ * finalised one), while the OpenAI Responses adapter fires it ONCE per block,
29
+ * already finalised, after the stream has ended. Treating "second sighting"
30
+ * as the only completion signal therefore left single-callback adapters with
31
+ * `block_start` events that never completed (#63 review). The tracker keeps
32
+ * the paired semantics and adds `flush()`, which the caller runs once the
33
+ * provider stream has returned: every started block that never saw a second
34
+ * callback is completed from the last block payload seen for it.
35
+ */
36
+ class NativeBlockTracker {
37
+ emit;
38
+ currentType = 'text';
39
+ blockIndex = 0;
40
+ started = new Map();
41
+ completed = new Set();
42
+ lastSeen = new Map();
43
+ constructor(emit) {
44
+ this.emit = emit;
45
+ }
46
+ static mapApiBlockType(apiType) {
47
+ if (apiType === 'thinking' || apiType === 'redacted_thinking' || apiType === 'reasoning')
48
+ return 'thinking';
49
+ if (apiType === 'tool_use' || apiType === 'function_call' || apiType === 'tool_call')
50
+ return 'tool_call';
51
+ return 'text';
52
+ }
53
+ /** Provider block callback: first sighting of an index starts it, a second completes it. */
54
+ onProviderBlock(index, block) {
55
+ this.lastSeen.set(index, block);
56
+ if (!this.started.has(index)) {
57
+ const mbType = NativeBlockTracker.mapApiBlockType(block?.type);
58
+ this.started.set(index, mbType);
59
+ this.currentType = mbType;
60
+ this.blockIndex = index;
61
+ this.emit?.({ event: 'block_start', index, block: { type: mbType } });
62
+ return;
63
+ }
64
+ this.complete(index, block);
65
+ }
66
+ /**
67
+ * Complete every started block that never received its second callback.
68
+ * Run after the provider stream has returned; idempotent, and a no-op for
69
+ * paired-callback adapters.
70
+ */
71
+ flush() {
72
+ for (const index of this.started.keys()) {
73
+ if (!this.completed.has(index))
74
+ this.complete(index, this.lastSeen.get(index));
75
+ }
76
+ }
77
+ /** Discard tracking state (refusal retry rolled the attempt back). */
78
+ reset() {
79
+ this.currentType = 'text';
80
+ this.blockIndex = 0;
81
+ this.started.clear();
82
+ this.completed.clear();
83
+ this.lastSeen.clear();
84
+ }
85
+ complete(index, block) {
86
+ if (this.completed.has(index))
87
+ return;
88
+ this.completed.add(index);
89
+ const mbType = this.started.get(index)
90
+ ?? NativeBlockTracker.mapApiBlockType(block?.type);
91
+ const apiBlock = block;
92
+ const mb = { type: mbType };
93
+ if (mbType === 'text')
94
+ mb.content = apiBlock?.text;
95
+ else if (mbType === 'thinking')
96
+ mb.content = apiBlock?.thinking;
97
+ else if (mbType === 'tool_call') {
98
+ mb.toolId = apiBlock?.id;
99
+ mb.toolName = apiBlock?.name;
100
+ mb.input = apiBlock?.input;
101
+ }
102
+ this.emit?.({ event: 'block_complete', index, block: mb });
103
+ }
104
+ }
18
105
  export class Membrane {
19
106
  adapter;
20
107
  registry;
@@ -47,17 +134,27 @@ export class Membrane {
47
134
  // refusal is a successful HTTP call with an unwanted verdict, and letting
48
135
  // it consume error retries would couple two unrelated budgets.
49
136
  let refusalRetriesUsed = 0;
137
+ // Spend on attempts we threw away. A refused attempt is a completed,
138
+ // billed HTTP call; reporting only the surviving attempt's usage
139
+ // under-reports the turn by one full call per retry.
140
+ let discardedUsage;
141
+ // One selection for the whole call: mode resolution and the build must
142
+ // name the same formatter instance (see resolveActiveFormatter).
143
+ const activeFormatter = this.resolveActiveFormatter(options.formatter);
50
144
  while (true) {
51
145
  attempts++;
52
146
  try {
53
- const { providerRequest, prefillResult } = this.transformRequest(request, options.formatter);
147
+ const { providerRequest, prefillResult } = this.transformRequest(request, activeFormatter);
54
148
  // Route through the single canonical hook helper so any future
55
149
  // change to hook semantics (logging, retry interaction, error
56
150
  // handling) applies to both complete() and the streaming paths.
57
151
  // Cast back to the local provider-request shape: the hook returns
58
152
  // `unknown` deliberately, and we acknowledge the cast at the boundary.
59
153
  const finalRequest = (await this.applyBeforeRequestHook(request, providerRequest));
60
- const providerResponse = await this.adapter.complete(finalRequest, {
154
+ // Last exit before the adapter: the only place that sees EVERY
155
+ // contribution (builder, formatter, passthrough, float, hook).
156
+ clampCacheMarkers(finalRequest, 'complete');
157
+ const rawProviderResponse = await this.adapter.complete(finalRequest, {
61
158
  signal: options.signal,
62
159
  timeoutMs: options.timeoutMs,
63
160
  onRequest: (req) => {
@@ -65,6 +162,11 @@ export class Membrane {
65
162
  options.onRequest?.(req);
66
163
  },
67
164
  });
165
+ // Restate usage in the one convention before any ratio or price sees it.
166
+ const providerResponse = {
167
+ ...rawProviderResponse,
168
+ usage: normalizeUsageToCacheExcluded(rawProviderResponse.usage, this.adapter.name, this.adapter.usageCacheConvention),
169
+ };
68
170
  // Call onResponse callback with raw response from API
69
171
  options.onResponse?.(providerResponse.raw);
70
172
  const response = this.transformResponse(providerResponse, request, prefillResult, startTime, attempts, rawRequest);
@@ -76,8 +178,16 @@ export class Membrane {
76
178
  if (response.stopReason === 'refusal' &&
77
179
  refusalRetriesUsed < Math.max(0, options.refusalRetries ?? 0)) {
78
180
  refusalRetriesUsed++;
181
+ discardedUsage = this.mergeDiscardedAttempts(discardedUsage, this.discardedAttemptFrom(response.usage));
79
182
  continue;
80
183
  }
184
+ // Report what the discarded attempts cost. Set BEFORE afterResponse
185
+ // so a hook that logs spend sees the whole turn, not just the
186
+ // attempt that stands.
187
+ if (discardedUsage) {
188
+ response.details.usage.discardedAttempts =
189
+ this.pricedDiscardedAttempts(discardedUsage, request.config.model);
190
+ }
81
191
  // Call afterResponse hook
82
192
  if (this.config.hooks?.afterResponse) {
83
193
  return await this.config.hooks.afterResponse(response, providerResponse.raw);
@@ -111,9 +221,17 @@ export class Membrane {
111
221
  throw new MembraneError(errorInfo);
112
222
  }
113
223
  }
114
- // Wait before retry (abort-aware)
224
+ // Wait before retry (abort-aware). An abort landing inside the
225
+ // sleep must fail like every other failure of this method — a
226
+ // MembraneError — rather than escaping the loop as a raw
227
+ // DOMException whose shape no caller of complete() expects.
115
228
  const delay = this.calculateRetryDelay(attempts, isOverloaded);
116
- await this.sleep(delay, options.signal);
229
+ try {
230
+ await this.sleep(delay, options.signal);
231
+ }
232
+ catch (sleepError) {
233
+ throw this.attachRawRequest(sleepError, rawRequest);
234
+ }
117
235
  continue;
118
236
  }
119
237
  throw new MembraneError(errorInfo);
@@ -142,6 +260,18 @@ export class Membrane {
142
260
  // If streaming is explicitly disabled on the request, fall back to complete()
143
261
  // and synthesize the streaming callbacks from the full response
144
262
  if (request.streaming === false) {
263
+ // complete() has no tool loop, and neither branch of this fallback can
264
+ // build one: honouring onToolCalls here would mean re-implementing the
265
+ // whole XML/native continuation machinery. Silently dropping it turned
266
+ // a working agent into one that narrates tool calls it never makes —
267
+ // the raw <function_calls> XML lands in the returned text and the turn
268
+ // ends. Refuse where the option is passed, before spending a call.
269
+ if (options.onToolCalls) {
270
+ throw unsupportedError('stream() cannot execute tools with streaming: false — the non-streaming ' +
271
+ 'fallback routes to complete(), which has no tool loop, so onToolCalls ' +
272
+ 'would never run. Leave streaming enabled (or drive the loop yourself ' +
273
+ 'with complete() per round).');
274
+ }
145
275
  const response = await this.complete(request, options);
146
276
  // Synthesize onChunk callbacks so callers that depend on them still work
147
277
  if (options.onChunk && 'content' in response) {
@@ -158,8 +288,9 @@ export class Membrane {
158
288
  }
159
289
  return response;
160
290
  }
161
- // Determine tool mode
162
- const toolMode = this.resolveToolMode(request);
291
+ // Determine tool mode against the formatter that will build the request
292
+ const activeFormatter = this.resolveActiveFormatter(options.formatter);
293
+ const toolMode = this.resolveToolMode(request, activeFormatter);
163
294
  const useNative = toolMode === 'native' && !!request.tools && request.tools.length > 0;
164
295
  // Overloaded (529) pre-emission retry. The streaming paths have no retry
165
296
  // loop of their own, so a capacity error used to kill the turn outright —
@@ -186,14 +317,16 @@ export class Membrane {
186
317
  };
187
318
  try {
188
319
  const result = useNative
189
- ? await this.streamWithNativeTools(request, tracked)
190
- : await this.streamWithXmlTools(request, tracked);
191
- // The inner paths report attempts: 1 they can't see this wrapper.
192
- // A call that succeeded after N overloaded retries must not look like
193
- // a first-attempt success in durable logs, so patch the real count
194
- // (and the waits) into the response telemetry.
320
+ ? await this.streamWithNativeTools(request, tracked, activeFormatter)
321
+ : await this.streamWithXmlTools(request, tracked, activeFormatter);
322
+ // The inner paths count their own provider calls but cannot see this
323
+ // wrapper's discarded attempts. Each failed attempt here died before
324
+ // emitting anything (that is the precondition for retrying), so it
325
+ // cost at least the one call it failed on — ADD those to the inner
326
+ // count rather than overwriting it, or a turn that retried twice and
327
+ // then ran three tool rounds would report 2 calls instead of 5.
195
328
  if (attempts > 1 && 'details' in result) {
196
- result.details.timing.attempts = attempts;
329
+ result.details.timing.attempts += attempts - 1;
197
330
  result.details.timing.retryDelaysMs = retryDelaysMs;
198
331
  }
199
332
  return result;
@@ -218,7 +351,21 @@ export class Membrane {
218
351
  }
219
352
  const delay = this.calculateRetryDelay(attempts, true);
220
353
  retryDelaysMs.push(delay);
221
- await this.sleep(delay, options.signal);
354
+ // An abort during the backoff window is still a cancellation of
355
+ // this stream, and stream() documents cancellation as an
356
+ // AbortedResponse. Letting the sleep's rejection escape made that
357
+ // contract depend on which millisecond the abort landed in.
358
+ // Nothing has been emitted on this path (that is the precondition
359
+ // for retrying at all), so there is no partial content to report.
360
+ try {
361
+ await this.sleep(delay, options.signal);
362
+ }
363
+ catch (sleepError) {
364
+ if (this.isAbortError(sleepError)) {
365
+ return this.buildAbortedResponse('', { inputTokens: 0, outputTokens: 0 }, [], [], this.abortReason(sleepError, options.signal));
366
+ }
367
+ throw sleepError;
368
+ }
222
369
  continue;
223
370
  }
224
371
  throw error;
@@ -226,17 +373,62 @@ export class Membrane {
226
373
  }
227
374
  }
228
375
  /**
229
- * Determine the effective tool mode
376
+ * Select the ACTIVE formatter for a request: the one instance that resolves
377
+ * its tool mode, builds its provider request, and parses its stream.
378
+ *
379
+ * A per-request override (`CompleteOptions.formatter` /
380
+ * `StreamOptions.formatter`) wins over the instance formatter, with ONE
381
+ * transport exception: the Responses adapter's input is a provider-native
382
+ * item array, and a generic override (for example Context Manager's
383
+ * NativeFormatter) produces Anthropic-style `{ role, content: [{ type:
384
+ * 'text' }] }` envelopes the Responses API rejects before inference — so a
385
+ * configured Responses formatter stays authoritative there.
386
+ *
387
+ * The exception is why this selection is a method rather than a `??` at each
388
+ * call site: while it lived inside transformRequest alone, the BUILD honored
389
+ * it and every other formatter reader resolved against a different instance,
390
+ * which is the split resolveToolMode exists to prevent, one layer down.
391
+ * Every entry point selects once, here, and threads the result.
230
392
  */
231
- resolveToolMode(request) {
393
+ resolveActiveFormatter(requestFormatter) {
394
+ if (this.adapter.name === 'openai-responses-api' && this.formatter.name === 'openai-responses') {
395
+ return this.formatter;
396
+ }
397
+ return requestFormatter ?? this.formatter;
398
+ }
399
+ /**
400
+ * Determine the effective tool mode.
401
+ *
402
+ * THE single source of truth for the mode: both complete() (via
403
+ * transformRequest → BuildOptions.toolMode) and the streaming paths (via
404
+ * their native-vs-XML path choice) resolve here, so a given request resolves
405
+ * to the same mode whichever entry point it arrives through.
406
+ *
407
+ * Precedence, strongest first:
408
+ * 1. an explicit non-'auto' `request.toolMode`
409
+ * 2. the mode the BUILDING formatter was explicitly constructed with
410
+ * (`AnthropicXmlFormatter({ toolMode: 'native' })`) — a caller's stated
411
+ * choice, not a derivation
412
+ * 3. formatter/provider derivation
413
+ *
414
+ * `formatter` is the formatter that will actually build the request — the
415
+ * instance `resolveActiveFormatter` selected for this call — because
416
+ * resolving against one formatter while building with another is exactly the
417
+ * split this method exists to prevent.
418
+ */
419
+ resolveToolMode(request, formatter = this.formatter) {
232
420
  // Explicit mode takes precedence
233
421
  if (request.toolMode && request.toolMode !== 'auto') {
234
422
  return request.toolMode;
235
423
  }
424
+ // A formatter constructed with an explicit mode states its caller's choice
425
+ if (formatter.configuredToolMode) {
426
+ return formatter.configuredToolMode;
427
+ }
236
428
  // Auto mode: choose based on formatter
237
429
  // NativeFormatter → native tools via API
238
430
  // AnthropicXmlFormatter (default) → XML tools in prefill
239
- if (this.formatter.name === 'native' || this.formatter.name === 'openai-responses') {
431
+ if (formatter.name === 'native' || formatter.name === 'openai-responses') {
240
432
  return 'native';
241
433
  }
242
434
  // Also handle known native-tool providers regardless of formatter
@@ -253,16 +445,25 @@ export class Membrane {
253
445
  * - False-positive stop sequence detection (e.g., "\nUser:" inside tool results)
254
446
  * - Structured block events for UI
255
447
  */
256
- async streamWithXmlTools(request, options) {
448
+ async streamWithXmlTools(request, options, activeFormatter = this.resolveActiveFormatter(options.formatter)) {
257
449
  const startTime = Date.now();
258
- const { onChunk, onContentBlockUpdate, onToolCalls, onPreToolContent, onUsage, onBlock, onRequest, onResponse, maxToolDepth = 10, signal, formatter: requestFormatter, } = options;
259
- // Use per-request formatter if provided, otherwise use instance formatter
260
- const formatter = requestFormatter ?? this.formatter;
450
+ const { onChunk, onContentBlockUpdate, onToolCalls, onPreToolContent, onUsage, onBlock, onRequest, onResponse, maxToolDepth = 10, signal, timeoutMs, idleTimeoutMs, } = options;
451
+ // The formatter stream() selected: the same instance that resolved the
452
+ // mode and will build the request, so the parser can never be reading a
453
+ // different format than the one on the wire.
454
+ const formatter = activeFormatter;
261
455
  // Initialize parser from formatter for format-specific tracking
262
456
  const parser = formatter.createStreamParser();
263
457
  let toolDepth = 0;
264
- let totalUsage = { inputTokens: 0, outputTokens: 0 };
265
- const pricing = this.resolvePricing(request.config.model);
458
+ // Each round is priced under the model that served THAT round and the
459
+ // costs are summed: a routed turn can change models mid-turn, and pricing
460
+ // the whole accumulated usage at the latest rate re-bills every earlier
461
+ // round at a price it was never charged.
462
+ const turnUsage = new TurnUsageAccumulator(request.config.model, (servedModel) => this.resolvePricing(request.config.model, servedModel));
463
+ // Honest turn telemetry: provider calls actually made (including refusal
464
+ // re-issues inside streamOnce) and continuation rounds.
465
+ let providerCalls = 0;
466
+ let rounds = 0;
266
467
  const contentBlocks = [];
267
468
  let lastStopReason = 'end_turn';
268
469
  let lastStopSequence;
@@ -337,13 +538,13 @@ export class Membrane {
337
538
  resumptionRounds++;
338
539
  if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
339
540
  warnLog.warn(`[membrane] automatic resumption at round ${resumptionRounds} ` +
340
- `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
541
+ `(${turnUsage.total.inputTokens} input tokens so far this turn) — ` +
341
542
  `a spin shows up here before it shows up on the bill`);
342
543
  }
343
544
  if (resumptionRounds > maxResumptionRounds) {
344
545
  warnLog.warn(`[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
345
546
  `ending turn with stopReason 'round_limit'. ` +
346
- `${totalUsage.inputTokens} input tokens spent this turn.`);
547
+ `${turnUsage.total.inputTokens} input tokens spent this turn.`);
347
548
  return false;
348
549
  }
349
550
  return true;
@@ -407,6 +608,8 @@ export class Membrane {
407
608
  : undefined,
408
609
  }, {
409
610
  signal,
611
+ timeoutMs,
612
+ idleTimeoutMs,
410
613
  normalizedRequest: request,
411
614
  // The tag-based parser tracks thinking via <thinking> tags — ask the
412
615
  // provider to wrap native thinking deltas so they don't stream as
@@ -417,6 +620,8 @@ export class Membrane {
417
620
  onRequest?.(req);
418
621
  },
419
622
  });
623
+ rounds++;
624
+ providerCalls += streamResult.providerCalls;
420
625
  // If we detected stop sequence manually, fix up the parser and result
421
626
  if (detectedStopSequence && truncatedAccumulated !== null) {
422
627
  parser.reset();
@@ -446,18 +651,11 @@ export class Membrane {
446
651
  onResponse?.(rawResponse);
447
652
  lastStopReason = this.mapStopReason(streamResult.stopReason);
448
653
  lastStopSequence = streamResult.stopSequence ?? undefined;
449
- // Accumulate usage (including cache metrics)
450
- totalUsage.inputTokens += streamResult.usage.inputTokens;
451
- totalUsage.outputTokens += streamResult.usage.outputTokens;
452
- if (streamResult.usage.cacheCreationTokens) {
453
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
454
- }
455
- if (streamResult.usage.cacheReadTokens) {
456
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
457
- }
458
- if (pricing)
459
- totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
460
- onUsage?.(totalUsage);
654
+ // Accumulate usage (including cache metrics), priced at this round's
655
+ // model. NOT inlined into the optional call — `onUsage?.(addRound())`
656
+ // skips evaluating its argument entirely when no callback is set.
657
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
658
+ onUsage?.(usageSoFar);
461
659
  // Flush the parser to complete any in-progress streaming block
462
660
  const flushResult = parser.flush();
463
661
  for (const emission of flushResult.emissions) {
@@ -485,7 +683,7 @@ export class Membrane {
485
683
  warnLog.warn(`[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
486
684
  `made no progress (${streamedThisRound} chars this round, stop ` +
487
685
  `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
488
- `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`);
686
+ `stopReason 'no_progress'. ${turnUsage.total.inputTokens} input tokens spent this turn.`);
489
687
  lastStopReason = 'no_progress';
490
688
  break;
491
689
  }
@@ -718,14 +916,14 @@ export class Membrane {
718
916
  // The full accumulated text is still available in raw.response
719
917
  const fullAccumulated = parser.getAccumulated();
720
918
  const newContent = fullAccumulated.slice(initialPrefillLength);
721
- const response = this.buildFinalResponse(newContent, contentBlocks, lastStopReason, totalUsage, request, prefillResult, startTime, 1, // attempts
722
- rawRequest, rawResponse, executedToolCalls, executedToolResults, initialBlockType, lastStopSequence);
919
+ const response = this.buildFinalResponse(newContent, contentBlocks, lastStopReason, turnUsage, request, prefillResult, startTime, providerCalls, rawRequest, rawResponse, executedToolCalls, executedToolResults, initialBlockType, lastStopSequence);
723
920
  // Append non-text content blocks (e.g., generated_image) that the XML parser can't handle
724
921
  if (extraContentBlocks.length > 0) {
725
922
  response.content.push(...extraContentBlocks);
726
923
  }
727
924
  // Merge provider thinking signatures into parser-derived thinking blocks
728
925
  this.mergeProviderThinkingBlocks(response.content, providerThinkingBlocks);
926
+ response.details.timing.rounds = rounds;
729
927
  return response;
730
928
  }
731
929
  catch (error) {
@@ -734,7 +932,7 @@ export class Membrane {
734
932
  // Only use NEW content (after initial prefill) for partial content
735
933
  const fullAccumulated = parser.getAccumulated();
736
934
  const newContent = fullAccumulated.slice(initialPrefillLength);
737
- return this.buildAbortedResponse(newContent, totalUsage, executedToolCalls, executedToolResults, 'user', initialBlockType);
935
+ return this.buildAbortedResponse(newContent, turnUsage.total, executedToolCalls, executedToolResults, this.abortReason(error, signal), initialBlockType);
738
936
  }
739
937
  // Re-throw with rawRequest attached for logging
740
938
  throw this.attachRawRequest(error, rawRequest);
@@ -743,12 +941,17 @@ export class Membrane {
743
941
  /**
744
942
  * Stream with native API tool execution
745
943
  */
746
- async streamWithNativeTools(request, options) {
944
+ async streamWithNativeTools(request, options, activeFormatter = this.resolveActiveFormatter(options.formatter)) {
747
945
  const startTime = Date.now();
748
- const { onChunk, onContentBlockUpdate, onToolCalls, onPreToolContent, onUsage, onRequest, onResponse, maxToolDepth = 10, signal, } = options;
946
+ const { onChunk, onContentBlockUpdate, onBlock, onToolCalls, onPreToolContent, onUsage, onRequest, onResponse, maxToolDepth = 10, signal, timeoutMs, idleTimeoutMs, } = options;
749
947
  let toolDepth = 0;
750
- let totalUsage = { inputTokens: 0, outputTokens: 0 };
751
- const pricing = this.resolvePricing(request.config.model);
948
+ // See streamWithXmlTools: one accumulator per turn, pricing each round
949
+ // under the model that served it.
950
+ const turnUsage = new TurnUsageAccumulator(request.config.model, (servedModel) => this.resolvePricing(request.config.model, servedModel));
951
+ // Honest turn telemetry: provider calls actually made (including refusal
952
+ // re-issues inside streamOnce) and continuation rounds.
953
+ let providerCalls = 0;
954
+ let rounds = 0;
752
955
  let lastStopReason = 'end_turn';
753
956
  let lastStopSequence;
754
957
  let rawRequest;
@@ -761,55 +964,69 @@ export class Membrane {
761
964
  // Build messages array that we'll update with tool results
762
965
  let messages = [...request.messages];
763
966
  let allContentBlocks = [];
967
+ let markersInLastRequest = 0;
764
968
  try {
765
969
  // Tool execution loop
766
970
  while (toolDepth <= maxToolDepth) {
767
971
  // Build provider request with native tools
768
- const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0);
972
+ const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0, activeFormatter);
769
973
  // Stream from provider
770
974
  let textAccumulated = '';
771
- let blockIndex = 0;
975
+ // Tag every token chunk with the membrane block it belongs to and
976
+ // surface the block lifecycle through onBlock — the same shape
977
+ // runNativeToolsYielding uses (#19). Before this, meta.type was
978
+ // hardcoded to 'text' on every chunk and onBlock was never invoked
979
+ // from this path (#20).
980
+ const tracker = new NativeBlockTracker(onBlock ? (event) => onBlock(event) : undefined);
772
981
  const streamResult = await this.streamOnce(providerRequest, {
773
982
  onChunk: (chunk) => {
774
983
  textAccumulated += chunk;
775
984
  allTextAccumulated += chunk;
776
- // For native mode, emit text chunks with basic metadata
777
- // TODO: Use native API content_block events for richer metadata
778
985
  const meta = {
779
- type: 'text',
780
- visible: true,
781
- blockIndex,
986
+ type: tracker.currentType,
987
+ visible: tracker.currentType === 'text',
988
+ blockIndex: tracker.blockIndex,
782
989
  };
783
990
  onChunk?.(chunk, meta);
784
991
  },
785
- onContentBlock: onContentBlockUpdate
786
- ? (index, block) => onContentBlockUpdate(index, block)
787
- : undefined,
992
+ onContentBlock: (index, block) => {
993
+ tracker.onProviderBlock(index, block);
994
+ // Deprecated pass-through, kept for callers still on it.
995
+ onContentBlockUpdate?.(index, block);
996
+ },
788
997
  }, {
789
998
  signal,
999
+ timeoutMs,
1000
+ idleTimeoutMs,
790
1001
  normalizedRequest: request,
791
1002
  onRequest: (req) => {
792
1003
  rawRequest = req;
793
1004
  onRequest?.(req);
794
1005
  },
1006
+ // Telemetry reports what this request actually SHIPPED with —
1007
+ // builder breakpoints, stale passthrough, fallback, float, plus
1008
+ // whatever the beforeRequest hook and the wire clamp did after
1009
+ // the build. Both native paths used to hardcode 0, and counting
1010
+ // at build time reported a number no request ever had.
1011
+ onWireCacheMarkers: (markerCount) => {
1012
+ markersInLastRequest = markerCount;
1013
+ },
795
1014
  });
1015
+ // Single-callback adapters (OpenAI Responses) report each finalised
1016
+ // block once, after the stream: complete whatever never saw a stop.
1017
+ tracker.flush();
1018
+ rounds++;
1019
+ providerCalls += streamResult.providerCalls;
796
1020
  rawResponse = streamResult.raw;
797
1021
  // Call onResponse callback with raw response from API
798
1022
  onResponse?.(rawResponse);
799
1023
  lastStopReason = this.mapStopReason(streamResult.stopReason);
800
1024
  lastStopSequence = streamResult.stopSequence ?? undefined;
801
- // Accumulate usage (including cache metrics)
802
- totalUsage.inputTokens += streamResult.usage.inputTokens;
803
- totalUsage.outputTokens += streamResult.usage.outputTokens;
804
- if (streamResult.usage.cacheCreationTokens) {
805
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
806
- }
807
- if (streamResult.usage.cacheReadTokens) {
808
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
809
- }
810
- if (pricing)
811
- totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
812
- onUsage?.(totalUsage);
1025
+ // Accumulate usage (including cache metrics), priced at this round's
1026
+ // model. NOT inlined into the optional call — `onUsage?.(addRound())`
1027
+ // skips evaluating its argument entirely when no callback is set.
1028
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
1029
+ onUsage?.(usageSoFar);
813
1030
  // Parse content blocks from response
814
1031
  const responseBlocks = this.parseProviderContent(streamResult.content);
815
1032
  allContentBlocks.push(...responseBlocks);
@@ -879,6 +1096,7 @@ export class Membrane {
879
1096
  break;
880
1097
  }
881
1098
  const durationMs = Date.now() - startTime;
1099
+ const totalUsage = turnUsage.total;
882
1100
  return {
883
1101
  content: allContentBlocks,
884
1102
  rawAssistantText: allTextAccumulated,
@@ -895,15 +1113,17 @@ export class Membrane {
895
1113
  usage: { ...totalUsage },
896
1114
  timing: {
897
1115
  totalDurationMs: durationMs,
898
- attempts: 1,
1116
+ attempts: providerCalls,
1117
+ rounds,
899
1118
  },
900
1119
  model: {
901
1120
  requested: request.config.model,
902
- actual: request.config.model,
1121
+ actual: turnUsage.lastServedModel || request.config.model,
903
1122
  provider: this.adapter.name,
1123
+ perRound: turnUsage.perRound,
904
1124
  },
905
1125
  cache: {
906
- markersInRequest: 0,
1126
+ markersInRequest: markersInLastRequest,
907
1127
  tokensCreated: totalUsage.cacheCreationTokens ?? 0,
908
1128
  tokensRead: totalUsage.cacheReadTokens ?? 0,
909
1129
  hitRatio: this.calculateCacheHitRatio(totalUsage),
@@ -918,28 +1138,60 @@ export class Membrane {
918
1138
  catch (error) {
919
1139
  // Check if this is an abort error
920
1140
  if (this.isAbortError(error)) {
921
- return this.buildAbortedResponse(allTextAccumulated, totalUsage, executedToolCalls, executedToolResults, 'user');
1141
+ return this.buildAbortedResponse(allTextAccumulated, turnUsage.total, executedToolCalls, executedToolResults, this.abortReason(error, signal));
922
1142
  }
923
1143
  // Re-throw with rawRequest attached for logging
924
1144
  throw this.attachRawRequest(error, rawRequest);
925
1145
  }
926
1146
  }
927
- /** See the floating-cache-marker block in buildNativeToolRequest. */
928
- floatBudgetWarned = false;
1147
+ /**
1148
+ * Rate-limit state for the float's budget warning. See the
1149
+ * floating-cache-marker block in buildNativeToolRequest.
1150
+ *
1151
+ * A once-per-instance latch made the ONLY observable of an over-budget wire
1152
+ * go quiet for the life of the process: a long-lived Membrane warns for the
1153
+ * first agent that trips it and never again, so the condition looks like it
1154
+ * healed. Warn on the first occurrence, then at most once per interval,
1155
+ * carrying the count of what was suppressed in between.
1156
+ */
1157
+ floatBudgetWarnState = { lastWarnedAtMs: 0, suppressedSinceWarn: 0 };
1158
+ static FLOAT_BUDGET_WARN_INTERVAL_MS = 60_000;
1159
+ warnFloatBudgetExhausted(wireMarkers) {
1160
+ const now = Date.now();
1161
+ const state = this.floatBudgetWarnState;
1162
+ const elapsed = now - state.lastWarnedAtMs;
1163
+ if (state.lastWarnedAtMs !== 0 && elapsed < Membrane.FLOAT_BUDGET_WARN_INTERVAL_MS) {
1164
+ state.suppressedSinceWarn++;
1165
+ return;
1166
+ }
1167
+ const suppressed = state.suppressedSinceWarn;
1168
+ state.lastWarnedAtMs = now;
1169
+ state.suppressedSinceWarn = 0;
1170
+ console.warn(`[membrane] floating cache marker withheld: upstream markers already ` +
1171
+ `occupy all ${MAX_CACHE_BREAKPOINTS} cache_control slots (${wireMarkers} on the wire). ` +
1172
+ `Tool-round suffixes will not cache incrementally.` +
1173
+ (suppressed > 0 ? ` (${suppressed} further occurrences suppressed since the last warning.)` : ''));
1174
+ }
929
1175
  /**
930
1176
  * Build a provider request with native tool support.
931
1177
  *
932
1178
  * `toolLoopRebuild` is true when this build is a tool-loop continuation
933
1179
  * (toolDepth > 0) rather than the turn's first request — the only case
934
1180
  * where the floating cache marker applies.
1181
+ *
1182
+ * `activeFormatter` is the formatter the caller selected for the request
1183
+ * (see resolveActiveFormatter). Reading `this.formatter` here instead made
1184
+ * the native loop build through the instance formatter while the mode had
1185
+ * been resolved against a per-request override — the two disagreeing about
1186
+ * which formatter is active.
935
1187
  */
936
- buildNativeToolRequest(request, messages, toolLoopRebuild = false) {
1188
+ buildNativeToolRequest(request, messages, toolLoopRebuild = false, activeFormatter = this.formatter) {
937
1189
  // Provider-native formatters own their complete input-item shape. The
938
1190
  // legacy implementation below is intentionally Anthropic-specific; using
939
1191
  // it for Responses would normalize away item IDs, encrypted reasoning,
940
1192
  // assistant phases, and compaction items.
941
- if (this.formatter.name === 'openai-responses') {
942
- return this.transformRequest({ ...request, messages }, this.formatter).providerRequest;
1193
+ if (activeFormatter.name === 'openai-responses') {
1194
+ return this.transformRequest({ ...request, messages }, activeFormatter).providerRequest;
943
1195
  }
944
1196
  // Convert messages to provider format
945
1197
  const providerMessages = [];
@@ -950,10 +1202,13 @@ export class Membrane {
950
1202
  // Anthropic allows at most 4 cache_control breakpoints per request. The
951
1203
  // message breakpoints are the valuable ones (they cache the longest prefixes,
952
1204
  // and every one already includes tools+system at the front of the request).
953
- // So tools/system get a breakpoint only as a FALLBACK — when no message
954
- // breakpoint was marked — otherwise they're redundant and would push the
955
- // total past 4, which the API hard-rejects (the agent goes unresponsive).
956
- let messageBreakpoints = 0;
1205
+ // So tools/system get a breakpoint only as a FALLBACK — when no marker
1206
+ // exists anywhere on the wire — otherwise they're redundant and would push
1207
+ // the total past 4, which the API hard-rejects (the agent goes
1208
+ // unresponsive). The fallback gate reads a RECOUNT of the built artifacts
1209
+ // (see below), never a running tally: a running tally cannot see a
1210
+ // caller-marked system block, and double-counts a message breakpoint that
1211
+ // lands on a block already carrying stale cache_control.
957
1212
  for (const msg of messages) {
958
1213
  const isAssistant = msg.participant === assistantName;
959
1214
  const role = isAssistant ? 'assistant' : 'user';
@@ -974,14 +1229,13 @@ export class Membrane {
974
1229
  }
975
1230
  const textBlock = { type: 'text', text };
976
1231
  if (block.cache_control) {
977
- textBlock.cache_control = block.cache_control;
978
1232
  // A block-level passthrough occupies one of the 4 breakpoint slots
979
- // exactly like a marked message count it, so the tools/system
980
- // fallback below doesn't stack more on top. (Imported/seeded
981
- // conversations can carry stale request-time cache_control on
982
- // stored blocks — first seen wedging Sill 2026-07-25: 3 cm markers
983
- // + 2 stale Arc-export blocks = 5 → hard 400 on every inference.)
984
- messageBreakpoints++;
1233
+ // exactly like a marked message; the recount below sees it.
1234
+ // (Imported/seeded conversations carry stale request-time
1235
+ // cache_control on stored blocks first seen wedging Sill
1236
+ // 2026-07-25: 3 cm markers + 2 stale Arc-export blocks = 5 → hard
1237
+ // 400 on every inference.)
1238
+ textBlock.cache_control = block.cache_control;
985
1239
  }
986
1240
  content.push(textBlock);
987
1241
  }
@@ -1058,7 +1312,6 @@ export class Membrane {
1058
1312
  const bpIdx = lastCacheableBlockIndex(content);
1059
1313
  if (bpIdx >= 0) {
1060
1314
  content[bpIdx].cache_control = cacheControl;
1061
- messageBreakpoints++;
1062
1315
  }
1063
1316
  }
1064
1317
  providerMessages.push({ role, content });
@@ -1087,14 +1340,31 @@ export class Membrane {
1087
1340
  // past one. `synthetic_pending_result` (not the downstream
1088
1341
  // cache_suppressed_for_synthetic, which only fires when a marker was
1089
1342
  // actually stripped) is the root condition.
1090
- let pendingResultSynthesized = false;
1343
+ // Every repair that REWRITES prefix bytes stands the float down, not just
1344
+ // the synthetic [pending] result: a textified orphan tool_result is
1345
+ // rewritten the same way when its real pairing arrives, so caching at or
1346
+ // past one poisons the prefix identically. The kinds live in one exported
1347
+ // set so a normalizer that grows a new prefix-rewriting repair cannot
1348
+ // silently escape this guard.
1349
+ let prefixRewritten = false;
1091
1350
  const normalized = normalizeToolPairs(providerMessages, {
1092
1351
  onEvent: (e) => {
1093
- if (e.kind === 'synthetic_pending_result')
1094
- pendingResultSynthesized = true;
1352
+ if (PREFIX_REWRITING_NORMALIZE_EVENT_KINDS.has(e.kind))
1353
+ prefixRewritten = true;
1095
1354
  },
1096
1355
  });
1097
1356
  const mergedMessages = mergeConsecutiveRoles(normalized.messages);
1357
+ // ONE recount of the constructed wire artifacts, taken BEFORE the
1358
+ // tools/system fallback decision so the fallback and the float share a
1359
+ // single truth. Counted post-normalize, so phase-5.5 cache suppression is
1360
+ // already reflected. `request.system` is the caller's own system content:
1361
+ // it explicitly accepts pre-marked blocks, and those are real wire markers
1362
+ // that no running tally ever saw (three of them plus both fallbacks = 5 on
1363
+ // the wire = a 400 on every inference of that config).
1364
+ const upstreamWireMarkers = countWireCacheMarkers({
1365
+ messages: mergedMessages,
1366
+ system: request.system,
1367
+ });
1098
1368
  // Convert tools to provider format.
1099
1369
  // Native tool names must match ^[a-zA-Z0-9_-]{1,128}$ — sanitize colons
1100
1370
  // from the module:tool namespace convention. Reversed in parseProviderContent.
@@ -1106,7 +1376,7 @@ export class Membrane {
1106
1376
  };
1107
1377
  // Cache the tool list (last tool) only as a fallback — a marked message
1108
1378
  // breakpoint already caches the tools as part of its prefix.
1109
- if (cacheControl && messageBreakpoints === 0 && request.tools && idx === request.tools.length - 1) {
1379
+ if (cacheControl && upstreamWireMarkers === 0 && request.tools && idx === request.tools.length - 1) {
1110
1380
  t.cache_control = cacheControl;
1111
1381
  }
1112
1382
  return t;
@@ -1114,11 +1384,11 @@ export class Membrane {
1114
1384
  // Wrap system prompt with cache_control only as a fallback (no message
1115
1385
  // breakpoint marked); otherwise a message breakpoint already caches
1116
1386
  // tools+system as part of its prefix.
1117
- let system = request.system;
1118
- if (cacheControl && messageBreakpoints === 0 && typeof system === 'string' && system.length > 0) {
1387
+ let system = ownSystemBlocks(request.system);
1388
+ if (cacheControl && upstreamWireMarkers === 0 && typeof system === 'string' && system.length > 0) {
1119
1389
  system = [{ type: 'text', text: system, cache_control: cacheControl }];
1120
1390
  }
1121
- else if (cacheControl && messageBreakpoints === 0 && Array.isArray(system) && system.length > 0) {
1391
+ else if (cacheControl && upstreamWireMarkers === 0 && Array.isArray(system) && system.length > 0) {
1122
1392
  const blocks = system;
1123
1393
  system = blocks.map((block, idx) => idx === blocks.length - 1 ? { ...block, cache_control: cacheControl } : block);
1124
1394
  }
@@ -1153,44 +1423,13 @@ export class Membrane {
1153
1423
  // normalizer's phase 5.5 cache suppression.
1154
1424
  // ------------------------------------------------------------------
1155
1425
  const floatingEnabled = request.floatingCacheMarker ?? this.config.defaultFloatingCacheMarker ?? true;
1156
- if (toolLoopRebuild && floatingEnabled && cacheControl && !pendingResultSynthesized) {
1157
- // Residuum from a RECOUNT of the constructed wire artifacts, not the
1158
- // running messageBreakpoints tally the tally diverges from the wire
1159
- // in both directions (mirrors NativeFormatter's recount, same bug
1160
- // class as the Sill 2026-07-25 wedge): a message-level breakpoint
1161
- // landing on a block already carrying stale cache_control is one
1162
- // physical marker counted twice, and a pre-marked system block is a
1163
- // real wire marker the tally never sees. Counted post-fallback and
1164
- // post-normalize, so fallback spend and phase-5.5 suppression are
1165
- // both reflected.
1166
- let wireMarkers = 0;
1167
- for (const m of mergedMessages) {
1168
- if (!Array.isArray(m.content))
1169
- continue;
1170
- for (const b of m.content) {
1171
- if (b.cache_control)
1172
- wireMarkers++;
1173
- }
1174
- }
1175
- if (tools)
1176
- for (const t of tools) {
1177
- if (t.cache_control)
1178
- wireMarkers++;
1179
- }
1180
- if (Array.isArray(system)) {
1181
- for (const b of system) {
1182
- if (b.cache_control)
1183
- wireMarkers++;
1184
- }
1185
- }
1186
- let residuum = 4 - wireMarkers;
1426
+ if (toolLoopRebuild && floatingEnabled && cacheControl && !prefixRewritten) {
1427
+ // Same recount as the fallback gate, re-taken POST-fallback so the
1428
+ // fallback's own spend is inside the residuum.
1429
+ const wireMarkers = countWireCacheMarkers({ messages: mergedMessages, system, tools });
1430
+ let residuum = MAX_CACHE_BREAKPOINTS - wireMarkers;
1187
1431
  if (residuum <= 0) {
1188
- if (!this.floatBudgetWarned) {
1189
- this.floatBudgetWarned = true;
1190
- console.warn(`[membrane] floating cache marker withheld: upstream markers already ` +
1191
- `occupy all 4 cache_control slots (${wireMarkers} on the wire). ` +
1192
- `Tool-round suffixes will not cache incrementally.`);
1193
- }
1432
+ this.warnFloatBudgetExhausted(wireMarkers);
1194
1433
  }
1195
1434
  else {
1196
1435
  // Newest message first; then the previous round's endpoint (two
@@ -1261,6 +1500,9 @@ export class Membrane {
1261
1500
  id: item.id,
1262
1501
  name: unsanitizeToolName(item.name),
1263
1502
  input: item.input,
1503
+ // Arguments that never parsed: carry the marker through so a
1504
+ // consumer can refuse the block instead of trusting `input`.
1505
+ ...(item.unparseableInput !== undefined ? { unparseableInput: item.unparseableInput } : {}),
1264
1506
  ...(item.rawItem ? { rawItem: item.rawItem } : {}),
1265
1507
  });
1266
1508
  }
@@ -1283,14 +1525,24 @@ export class Membrane {
1283
1525
  mimeType: item.mimeType,
1284
1526
  });
1285
1527
  }
1286
- else if (item.rawItem) {
1528
+ else if (item.rawItem || item.type) {
1287
1529
  // Opaque Responses items such as encrypted compaction or custom
1288
1530
  // tool records have no normalized ContentBlock equivalent. Retain a
1289
1531
  // zero-width carrier so Chronicle and the Responses formatter can
1290
1532
  // replay the raw item without surfacing synthetic prompt text.
1291
1533
  // Anthropic-bound conversion paths filter these out (empty text
1292
1534
  // blocks are a 400 there); the Responses formatter replays rawItem.
1293
- blocks.push({ type: 'text', text: '', rawItem: item.rawItem });
1535
+ //
1536
+ // An item with a `type` this switch does not know (server_tool_use,
1537
+ // web_search_tool_result, search_result, mcp_tool_use, or whatever a
1538
+ // provider adds next) used to fall out of this chain and vanish. It
1539
+ // gets the same carrier treatment, holding the item itself, plus a
1540
+ // one-time warning so the gap surfaces instead of being inferred
1541
+ // later from missing content.
1542
+ const carriedRawItem = item.rawItem ?? item;
1543
+ if (!item.rawItem)
1544
+ warnUnconvertibleProviderItem(item.type);
1545
+ blocks.push({ type: 'text', text: '', rawItem: carriedRawItem });
1294
1546
  }
1295
1547
  }
1296
1548
  return blocks;
@@ -1324,10 +1576,32 @@ export class Membrane {
1324
1576
  }
1325
1577
  /**
1326
1578
  * Merge provider thinking signatures into parser-derived thinking blocks
1327
- * (matched in stream order), and prepend any leftover provider blocks —
1328
- * signature-only thinking (display:'omitted') never appears in the text
1329
- * stream, so the parser produces no block for it. redacted_thinking
1330
- * blocks are always prepended verbatim.
1579
+ * and prepend any leftover provider blocks — signature-only thinking
1580
+ * (display:'omitted') never appears in the text stream, so the parser
1581
+ * produces no block for it. redacted_thinking blocks are always prepended
1582
+ * verbatim.
1583
+ *
1584
+ * Pairing is by CONTENT IDENTITY, never by index. The two lists are
1585
+ * differently shaped whenever the provider emits a block the parser cannot
1586
+ * see (signature-only), the parser emits a block the provider never
1587
+ * produced (the XML path's literal `Claude: <thinking>` prefill turns
1588
+ * VISIBLE text into a thinking block), or one provider block spans several
1589
+ * (auto-continuation: capture runs per round while the parser sees the
1590
+ * CONCATENATED accumulation). Index-zipping crosses the lists in all three
1591
+ * shapes and stamps a signature onto content that never produced it —
1592
+ * which round-trips into the consumer's stored history and fails Anthropic
1593
+ * signature validation on the next turn.
1594
+ *
1595
+ * The three rules, in order:
1596
+ * 1. identity — a provider block pairs with the parsed block whose
1597
+ * thinking text is the same; empty-thinking (signature-only) blocks
1598
+ * are never text-match candidates and are prepend-only.
1599
+ * 2. span — a parsed block that reconstructs as the concatenation of a
1600
+ * RUN of consecutive unpaired provider blocks is REPLACED in place by
1601
+ * those originals, so the spanning block never wears a fragment's
1602
+ * signature and no reasoning is sent twice.
1603
+ * 3. leftover — everything still unpaired is prepended, de-duplicated
1604
+ * against what `content` already carries (and against itself).
1331
1605
  *
1332
1606
  * Mutates `content` in place. Shared by the XML stream paths
1333
1607
  * (streamWithXmlTools and runXmlToolsYielding).
@@ -1335,20 +1609,70 @@ export class Membrane {
1335
1609
  mergeProviderThinkingBlocks(content, providerThinkingBlocks) {
1336
1610
  if (providerThinkingBlocks.length === 0)
1337
1611
  return;
1338
- const parsedThinking = content.filter((b) => b.type === 'thinking');
1339
1612
  const providerThinking = providerThinkingBlocks.filter((b) => b.type === 'thinking');
1340
1613
  const redacted = providerThinkingBlocks.filter((b) => b.type === 'redacted_thinking');
1341
- const matched = Math.min(providerThinking.length, parsedThinking.length);
1342
- for (let i = 0; i < matched; i++) {
1343
- const sig = providerThinking[i].signature;
1344
- if (sig) {
1345
- parsedThinking[i].signature = sig;
1346
- }
1614
+ const pairedProviderBlocks = new Set();
1615
+ const claimedParsedIndices = new Set();
1616
+ const parsedThinkingIndices = () => content.reduce((acc, block, index) => {
1617
+ if (block.type === 'thinking')
1618
+ acc.push(index);
1619
+ return acc;
1620
+ }, []);
1621
+ for (let p = 0; p < providerThinking.length; p++) {
1622
+ const providerText = providerThinking[p].thinking ?? '';
1623
+ if (providerText === '')
1624
+ continue;
1625
+ const match = parsedThinkingIndices().find((index) => !claimedParsedIndices.has(index) &&
1626
+ sameThinkingText(content[index].thinking ?? '', providerText));
1627
+ if (match === undefined)
1628
+ continue;
1629
+ const signature = providerThinking[p].signature;
1630
+ if (signature)
1631
+ content[match].signature = signature;
1632
+ claimedParsedIndices.add(match);
1633
+ pairedProviderBlocks.add(p);
1634
+ }
1635
+ for (const parsedIndex of parsedThinkingIndices().reverse()) {
1636
+ if (claimedParsedIndices.has(parsedIndex))
1637
+ continue;
1638
+ const parsedText = content[parsedIndex].thinking ?? '';
1639
+ if (parsedText === '')
1640
+ continue;
1641
+ const run = findSpanningProviderRun(providerThinking, pairedProviderBlocks, parsedText);
1642
+ if (!run)
1643
+ continue;
1644
+ content.splice(parsedIndex, 1, ...run.map((p) => {
1645
+ pairedProviderBlocks.add(p);
1646
+ const block = providerThinking[p];
1647
+ return {
1648
+ type: 'thinking',
1649
+ thinking: block.thinking ?? '',
1650
+ ...(block.signature ? { signature: block.signature } : {}),
1651
+ };
1652
+ }));
1653
+ claimedParsedIndices.add(parsedIndex);
1347
1654
  }
1348
- const leftover = providerThinking.slice(matched);
1349
- if (leftover.length > 0 || redacted.length > 0) {
1350
- content.unshift(...leftover, ...redacted);
1655
+ const seen = new Set(content.map((block) => thinkingCarrierKey(block)));
1656
+ const leftover = [];
1657
+ for (let p = 0; p < providerThinking.length; p++) {
1658
+ if (pairedProviderBlocks.has(p))
1659
+ continue;
1660
+ const block = providerThinking[p];
1661
+ const key = thinkingCarrierKey(block);
1662
+ if (seen.has(key))
1663
+ continue;
1664
+ seen.add(key);
1665
+ leftover.push(block);
1351
1666
  }
1667
+ for (const block of redacted) {
1668
+ const key = thinkingCarrierKey(block);
1669
+ if (seen.has(key))
1670
+ continue;
1671
+ seen.add(key);
1672
+ leftover.push(block);
1673
+ }
1674
+ if (leftover.length > 0)
1675
+ content.unshift(...leftover);
1352
1676
  }
1353
1677
  // ==========================================================================
1354
1678
  // Internal Methods
@@ -1424,19 +1748,15 @@ export class Membrane {
1424
1748
  return { type: 'enabled', budget_tokens: budget, ...(display ? { display } : {}) };
1425
1749
  }
1426
1750
  /**
1427
- * Transform a normalized request into provider format using the formatter
1751
+ * Transform a normalized request into provider format using the formatter.
1752
+ *
1753
+ * `activeFormatter` is the instance the caller already selected via
1754
+ * resolveActiveFormatter — including that selection's Responses-transport
1755
+ * authority rule, which used to live inline here. It is a parameter and not
1756
+ * a re-derivation so that the formatter which BUILDS is the same one that
1757
+ * resolved the tool mode and drives the loop.
1428
1758
  */
1429
- transformRequest(request, formatter) {
1430
- // The Responses adapter's input is a provider-native item array. A generic
1431
- // per-request formatter (for example Context Manager's NativeFormatter)
1432
- // produces Anthropic-style `{ role, content: [{ type: 'text' }] }`
1433
- // envelopes, which the Responses API rejects before inference. Keep the
1434
- // configured Responses formatter authoritative at this transport boundary;
1435
- // per-request formatter overrides remain available for adapters whose wire
1436
- // format supports them.
1437
- const activeFormatter = this.adapter.name === 'openai-responses-api' && this.formatter.name === 'openai-responses'
1438
- ? this.formatter
1439
- : formatter ?? this.formatter;
1759
+ transformRequest(request, activeFormatter = this.formatter) {
1440
1760
  // Extract user-provided stop sequences
1441
1761
  const additionalStopSequences = Array.isArray(request.stopSequences)
1442
1762
  ? request.stopSequences
@@ -1450,6 +1770,10 @@ export class Membrane {
1450
1770
  participantMode: 'multiuser',
1451
1771
  assistantParticipant: request.assistantParticipant ?? this.config.assistantParticipant ?? 'Claude',
1452
1772
  tools: request.tools,
1773
+ // One resolution for every entry point: complete() used to build from the
1774
+ // formatter's constructor-time mode alone, so request.toolMode was a
1775
+ // second, disconnected source of truth on this path.
1776
+ toolMode: this.resolveToolMode(request, activeFormatter),
1453
1777
  thinking: request.config.thinking,
1454
1778
  systemPrompt: request.system,
1455
1779
  promptCaching: request.promptCaching ?? this.config.defaultPromptCaching ?? true, // Default true for backward compat
@@ -1473,7 +1797,9 @@ export class Membrane {
1473
1797
  const providerRequest = {
1474
1798
  ...this.getBaseProviderParams(request.config),
1475
1799
  messages: buildResult.messages,
1476
- system: buildResult.systemContent,
1800
+ // Owned, not aliased: the wire clamp strips markers in place, and a
1801
+ // formatter may pass the caller's own system array straight through.
1802
+ system: ownSystemBlocks(buildResult.systemContent),
1477
1803
  stopSequences: buildResult.stopSequences,
1478
1804
  tools: buildResult.nativeTools,
1479
1805
  extra: {
@@ -1486,8 +1812,8 @@ export class Membrane {
1486
1812
  // literal `<thinking>` text prefix instead of the API feature — drop the
1487
1813
  // API param when the built request actually ends in an assistant prefill.
1488
1814
  // Chat-style builds (no prefill) keep it.
1489
- if (buildResult.assistantPrefill && providerRequest.thinking) {
1490
- delete providerRequest.thinking;
1815
+ if (buildResult.assistantPrefill) {
1816
+ stripThinkingForPrefill(providerRequest);
1491
1817
  }
1492
1818
  return { providerRequest, prefillResult: buildResult };
1493
1819
  }
@@ -1497,17 +1823,42 @@ export class Membrane {
1497
1823
  // compatibility won't catch the excess field (checked only on object
1498
1824
  // literals, not on variables). Leaving it in would silently leak the
1499
1825
  // normalized form into every adapter's options.
1500
- const { normalizedRequest, refusalRetries, onRetrying, ...adapterOptions } = options;
1826
+ const { normalizedRequest, refusalRetries, onRetrying, onWireCacheMarkers, ...adapterOptions } = options;
1501
1827
  const finalRequest = (await this.applyBeforeRequestHook(normalizedRequest, request));
1828
+ // Last exit before the adapter: the only place that sees EVERY
1829
+ // contribution (builder, formatter, passthrough, float, hook). Every
1830
+ // streaming path — stream(), streamYielding(), both tool loops — funnels
1831
+ // through here, so this is the one clamp they all get, and its tally is
1832
+ // therefore the only count that describes the wire.
1833
+ const clampOutcome = clampCacheMarkers(finalRequest, 'streamOnce');
1834
+ onWireCacheMarkers?.(clampOutcome.total);
1502
1835
  // Retries are only safe when the caller can discard the abandoned
1503
1836
  // attempt, so they require BOTH a budget and an onRetrying hook.
1504
1837
  const maxAttempts = onRetrying ? Math.max(0, refusalRetries ?? 0) : 0;
1505
1838
  let retried = 0;
1839
+ // Every re-issued attempt was a completed, billed provider call. The
1840
+ // caller's usage accumulator only ever sees the surviving result, so the
1841
+ // abandoned spend rides back out on the result itself.
1842
+ let discardedUsage;
1843
+ let providerCalls = 0;
1506
1844
  while (true) {
1507
- const result = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1508
- if (result.stopReason !== 'refusal' || retried >= maxAttempts)
1509
- return result;
1845
+ providerCalls++;
1846
+ const rawResult = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1847
+ // Restate usage in the one convention before any accumulator, ratio or
1848
+ // price sees it — this is the only door streamed usage enters through.
1849
+ const result = {
1850
+ ...rawResult,
1851
+ usage: normalizeUsageToCacheExcluded(rawResult.usage, this.adapter.name, this.adapter.usageCacheConvention),
1852
+ };
1853
+ if (result.stopReason !== 'refusal' || retried >= maxAttempts) {
1854
+ return {
1855
+ ...result,
1856
+ providerCalls,
1857
+ ...(discardedUsage ? { discardedUsage } : {}),
1858
+ };
1859
+ }
1510
1860
  retried++;
1861
+ discardedUsage = this.mergeDiscardedAttempts(discardedUsage, this.discardedAttemptFrom(result.usage));
1511
1862
  const category = result.raw
1512
1863
  ?.response?.stop_details?.category;
1513
1864
  onRetrying({ attempt: retried, maxAttempts, category });
@@ -1516,6 +1867,12 @@ export class Membrane {
1516
1867
  buildContinuationRequest(originalRequest, prefillResult, accumulated) {
1517
1868
  // Anthropic quirk: assistant content cannot end with trailing whitespace
1518
1869
  const trimmedAccumulated = accumulated.trimEnd();
1870
+ // Everything before the watermark already rides EARLIER messages (a
1871
+ // persisted split turn), so only the suffix belongs in the trailing
1872
+ // assistant prefill — replacing it with the whole document would
1873
+ // duplicate the pre-seam text and flatten the image user-turn away.
1874
+ const baseOffset = prefillResult.accumulatedBaseOffset ?? 0;
1875
+ const trailingContent = accumulated.slice(baseOffset).trimEnd();
1519
1876
  // Build continuation messages: keep all messages up to last assistant,
1520
1877
  // then replace/add the accumulated content
1521
1878
  const messages = [...prefillResult.messages];
@@ -1523,32 +1880,32 @@ export class Membrane {
1523
1880
  let foundAssistant = false;
1524
1881
  for (let i = messages.length - 1; i >= 0; i--) {
1525
1882
  if (messages[i]?.role === 'assistant') {
1526
- messages[i] = { role: 'assistant', content: trimmedAccumulated };
1883
+ messages[i] = { role: 'assistant', content: trailingContent };
1527
1884
  foundAssistant = true;
1528
1885
  break;
1529
1886
  }
1530
1887
  }
1531
1888
  if (!foundAssistant) {
1532
- messages.push({ role: 'assistant', content: trimmedAccumulated });
1889
+ messages.push({ role: 'assistant', content: trailingContent });
1533
1890
  }
1534
- return {
1891
+ return stripThinkingForPrefill({
1535
1892
  ...this.getBaseProviderParams(originalRequest.config),
1536
1893
  // Continuations always end in an assistant prefill — the API rejects
1537
1894
  // extended thinking combined with prefill, so never send the param here
1538
1895
  thinking: undefined,
1539
1896
  messages,
1540
- system: prefillResult.systemContent
1541
- ? (Array.isArray(prefillResult.systemContent) && prefillResult.systemContent.length > 0
1542
- ? prefillResult.systemContent
1543
- : prefillResult.systemContent)
1544
- : undefined,
1897
+ system: ownSystemBlocks(prefillResult.systemContent) ?? undefined,
1545
1898
  stopSequences: prefillResult.stopSequences,
1546
1899
  extra: {
1547
1900
  ...originalRequest.providerParams,
1901
+ // Same contract transformRequest sends: adapters that reason about
1902
+ // the normalized shape (or fall back to serializing it) must not see
1903
+ // a continuation as a request with no normalized form at all.
1904
+ normalizedMessages: originalRequest.messages,
1548
1905
  // Pre-serialized prompt for completions adapters — skip re-serialization
1549
1906
  prompt: trimmedAccumulated,
1550
1907
  },
1551
- };
1908
+ });
1552
1909
  }
1553
1910
  /**
1554
1911
  * Build continuation request with split-turn image injection.
@@ -1570,6 +1927,11 @@ export class Membrane {
1570
1927
  buildContinuationRequestWithImages(originalRequest, prefillResult, accumulated, images, afterImageXml) {
1571
1928
  // Anthropic quirk: assistant content cannot end with trailing whitespace
1572
1929
  const trimmedAccumulated = accumulated.trimEnd();
1930
+ // The split replaces only the CURRENT trailing assistant message, which
1931
+ // covers the accumulated text from the previous seam onward (0 on the
1932
+ // first split, the previous image seam on a later one).
1933
+ const baseOffset = prefillResult.accumulatedBaseOffset ?? 0;
1934
+ const trailingContent = accumulated.slice(baseOffset).trimEnd();
1573
1935
  // Build messages: copy all, then replace only the last assistant with split-turn
1574
1936
  const messages = prefillResult.messages.map(msg => ({ ...msg }));
1575
1937
  // Find last assistant — replace in-place via splice to preserve history
@@ -1583,7 +1945,7 @@ export class Membrane {
1583
1945
  // Anthropic quirk: assistant content cannot end with trailing whitespace
1584
1946
  const trimmedAfterXml = afterImageXml.trimEnd();
1585
1947
  const splitTurnMessages = [
1586
- { role: 'assistant', content: trimmedAccumulated },
1948
+ { role: 'assistant', content: trailingContent },
1587
1949
  { role: 'user', content: images },
1588
1950
  { role: 'assistant', content: trimmedAfterXml },
1589
1951
  ];
@@ -1593,20 +1955,39 @@ export class Membrane {
1593
1955
  else {
1594
1956
  messages.push(...splitTurnMessages);
1595
1957
  }
1596
- return {
1958
+ // PERSIST the split. Later rounds rebuild from prefillResult.messages;
1959
+ // without this the image user-turn exists on exactly one request and the
1960
+ // next continuation flattens the accumulated document back over it —
1961
+ // leaving <function_results> XML asserting a screenshot the model can no
1962
+ // longer see. Reassign (never mutate in place): the previous array is
1963
+ // still referenced by the request already on the wire. The watermark
1964
+ // moves to the seam — the point in `accumulated` where afterImageXml is
1965
+ // about to be appended — so the next builder replaces only the closing
1966
+ // assistant turn.
1967
+ prefillResult.messages = messages;
1968
+ prefillResult.accumulatedBaseOffset = accumulated.length;
1969
+ return stripThinkingForPrefill({
1597
1970
  ...this.getBaseProviderParams(originalRequest.config),
1598
1971
  // Continuations always end in an assistant prefill — the API rejects
1599
1972
  // extended thinking combined with prefill, so never send the param here
1600
1973
  thinking: undefined,
1601
1974
  messages,
1602
- system: prefillResult.systemContent
1603
- ? (Array.isArray(prefillResult.systemContent) && prefillResult.systemContent.length > 0
1604
- ? prefillResult.systemContent
1605
- : prefillResult.systemContent)
1606
- : undefined,
1975
+ system: ownSystemBlocks(prefillResult.systemContent) ?? undefined,
1607
1976
  stopSequences: prefillResult.stopSequences,
1608
- extra: originalRequest.providerParams,
1609
- };
1977
+ // Copied, not aliased: the guard below deletes the smuggled thinking
1978
+ // config, and mutating the caller's own providerParams object would
1979
+ // silently disable thinking on their NEXT (non-prefill) request.
1980
+ extra: {
1981
+ ...originalRequest.providerParams,
1982
+ // Same contract as transformRequest and the plain continuation
1983
+ // builder. Without these a completions-style adapter fell through to
1984
+ // serializing PROVIDER-shaped messages as if they were normalized
1985
+ // ones, re-adding participant stop sequences the continuation
1986
+ // deliberately suppresses.
1987
+ normalizedMessages: originalRequest.messages,
1988
+ prompt: trimmedAccumulated,
1989
+ },
1990
+ });
1610
1991
  }
1611
1992
  transformResponse(providerResponse, request, prefillResult, startTime, attempts, rawRequest) {
1612
1993
  // Extract text from response
@@ -1674,6 +2055,7 @@ export class Membrane {
1674
2055
  }
1675
2056
  // Parse XML tool calls from text if no native tool_use blocks were found
1676
2057
  // This handles prefill mode where tools are XML in the text
2058
+ let emptyToolBlocks = 0;
1677
2059
  if (toolCalls.length === 0 && rawAssistantText.includes('<function_calls>')) {
1678
2060
  const parsed = parseToolCalls(rawAssistantText);
1679
2061
  if (parsed?.calls.length) {
@@ -1681,12 +2063,25 @@ export class Membrane {
1681
2063
  toolCalls.push(tc);
1682
2064
  }
1683
2065
  }
2066
+ else if (parsed) {
2067
+ emptyToolBlocks = 1;
2068
+ }
1684
2069
  }
2070
+ const unclosedToolBlock = endsWithPartialToolBlock(rawAssistantText);
1685
2071
  const stopReason = this.mapStopReason(providerResponse.stopReason);
2072
+ this.reportToolParseDiagnostics({ unclosedToolBlock, emptyToolBlocks }, stopReason);
1686
2073
  const durationMs = Date.now() - startTime;
2074
+ // `NormalizedResponse.usage` is typed DetailedUsage and the streaming paths
2075
+ // already return the whole thing; complete() used to narrow it to
2076
+ // input/output here, so a caller reading `response.usage.cacheReadTokens`
2077
+ // saw undefined on one path and a number on the other.
1687
2078
  const usage = {
1688
2079
  inputTokens: providerResponse.usage.inputTokens,
1689
2080
  outputTokens: providerResponse.usage.outputTokens,
2081
+ cacheCreationTokens: providerResponse.usage.cacheCreationTokens,
2082
+ cacheReadTokens: providerResponse.usage.cacheReadTokens,
2083
+ thinkingTokens: providerResponse.usage.thinkingTokens,
2084
+ estimatedCost: this.estimateCost(providerResponse.usage, request.config.model, providerResponse.model),
1690
2085
  };
1691
2086
  return {
1692
2087
  content,
@@ -1700,14 +2095,9 @@ export class Membrane {
1700
2095
  reason: stopReason,
1701
2096
  triggeredSequence: providerResponse.stopSequence,
1702
2097
  wasTruncated: stopReason === 'max_tokens',
2098
+ unclosedToolBlock,
1703
2099
  },
1704
- usage: {
1705
- inputTokens: providerResponse.usage.inputTokens,
1706
- outputTokens: providerResponse.usage.outputTokens,
1707
- cacheCreationTokens: providerResponse.usage.cacheCreationTokens,
1708
- cacheReadTokens: providerResponse.usage.cacheReadTokens,
1709
- estimatedCost: this.estimateCost(providerResponse.usage, request.config.model),
1710
- },
2100
+ usage,
1711
2101
  timing: {
1712
2102
  totalDurationMs: durationMs,
1713
2103
  attempts,
@@ -1730,12 +2120,47 @@ export class Membrane {
1730
2120
  },
1731
2121
  };
1732
2122
  }
1733
- buildFinalResponse(accumulated, contentBlocks, stopReason, usage, request, prefillResult, startTime, attempts, rawRequest, rawResponse, executedToolCalls = [], executedToolResults = [], startInsideBlock = null, triggeredSequence) {
2123
+ /**
2124
+ * The turn is over, and the two guards that detect a half-written tool block
2125
+ * finally have a call site. Both shapes are defects a consumer must not
2126
+ * persist blind: an unclosed block splices onto the NEXT round's closing tag
2127
+ * (the loop does not resume on a length stop, so max_tokens leaves exactly
2128
+ * this), and a block that parsed to nothing means the model believes it
2129
+ * called a tool that never ran.
2130
+ */
2131
+ reportToolParseDiagnostics(diagnostics, stopReason) {
2132
+ const warnLog = this.config.logger ?? console;
2133
+ if (diagnostics.unclosedToolBlock) {
2134
+ warnLog.warn(`[membrane] turn ended (${stopReason}) with an unclosed tool block in the ` +
2135
+ `assistant text — the loop does not resume on a length stop. Persisting this ` +
2136
+ `turn verbatim lets the next round's closing tag splice onto the stale ` +
2137
+ `opener; see details.stop.unclosedToolBlock.`);
2138
+ }
2139
+ if (diagnostics.emptyToolBlocks > 0) {
2140
+ warnLog.warn(`[membrane] ${diagnostics.emptyToolBlocks} function_calls block(s) parsed to ` +
2141
+ `zero tool calls — always a defect, never a normal ending. The call was ` +
2142
+ `returned as assistant text and nothing executed.`);
2143
+ }
2144
+ if (diagnostics.splicedToolBlocks) {
2145
+ warnLog.warn(`[membrane] ${diagnostics.splicedToolBlocks} tool block(s) spanned a second ` +
2146
+ `<function_calls> opener and were re-anchored to the innermost one — an ` +
2147
+ `earlier truncated block is present in this conversation's assistant text.`);
2148
+ }
2149
+ if (diagnostics.unclosedInvokeHeads) {
2150
+ warnLog.warn(`[membrane] ${diagnostics.unclosedInvokeHeads} <invoke> head(s) were left ` +
2151
+ `unclosed and swallowed the invoke that followed — nothing was dispatched ` +
2152
+ `under an unclosed head's name, and the call it absorbed was re-anchored ` +
2153
+ `and ran with its own parameters.`);
2154
+ }
2155
+ }
2156
+ buildFinalResponse(accumulated, contentBlocks, stopReason, turnUsage, request, prefillResult, startTime, attempts, rawRequest, rawResponse, executedToolCalls = [], executedToolResults = [], startInsideBlock = null, triggeredSequence) {
2157
+ const usage = turnUsage.total;
1734
2158
  // Parse accumulated text into structured content blocks
1735
2159
  // This extracts thinking, tool_use, tool_result, and text blocks
1736
2160
  let finalContent;
1737
2161
  let toolCalls;
1738
2162
  let toolResults;
2163
+ let unclosedToolBlock = false;
1739
2164
  if (contentBlocks.length > 0) {
1740
2165
  // Native mode - content blocks already structured
1741
2166
  finalContent = contentBlocks;
@@ -1751,6 +2176,8 @@ export class Membrane {
1751
2176
  finalContent = parsed.blocks;
1752
2177
  toolCalls = parsed.toolCalls.length > 0 ? parsed.toolCalls : executedToolCalls;
1753
2178
  toolResults = parsed.toolResults.length > 0 ? parsed.toolResults : executedToolResults;
2179
+ unclosedToolBlock = parsed.unclosedToolBlock;
2180
+ this.reportToolParseDiagnostics(parsed, stopReason);
1754
2181
  }
1755
2182
  const durationMs = Date.now() - startTime;
1756
2183
  return {
@@ -1765,19 +2192,21 @@ export class Membrane {
1765
2192
  reason: stopReason,
1766
2193
  triggeredSequence,
1767
2194
  wasTruncated: stopReason === 'max_tokens',
2195
+ unclosedToolBlock,
1768
2196
  },
1769
- usage: {
1770
- ...usage,
1771
- estimatedCost: usage.estimatedCost ?? this.estimateCost(usage, request.config.model),
1772
- },
2197
+ // Priced per round by the accumulator and summed — NOT re-derived here
2198
+ // from the turn totals, which would re-bill every round at the last
2199
+ // model's rate.
2200
+ usage,
1773
2201
  timing: {
1774
2202
  totalDurationMs: durationMs,
1775
2203
  attempts,
1776
2204
  },
1777
2205
  model: {
1778
2206
  requested: request.config.model,
1779
- actual: request.config.model, // TODO: get from response
2207
+ actual: turnUsage.lastServedModel || request.config.model,
1780
2208
  provider: this.adapter.name,
2209
+ perRound: turnUsage.perRound,
1781
2210
  },
1782
2211
  cache: {
1783
2212
  markersInRequest: prefillResult.cacheMarkersApplied ?? 0,
@@ -1792,6 +2221,45 @@ export class Membrane {
1792
2221
  },
1793
2222
  };
1794
2223
  }
2224
+ /**
2225
+ * Fold one discarded (billed but abandoned) attempt's usage into a carry.
2226
+ * Returns a NEW object so a caller's earlier snapshot is never mutated.
2227
+ */
2228
+ mergeDiscardedAttempts(carry, add) {
2229
+ if (!add)
2230
+ return carry;
2231
+ const next = carry
2232
+ ? { ...carry }
2233
+ : { attempts: 0, inputTokens: 0, outputTokens: 0 };
2234
+ next.attempts += add.attempts;
2235
+ next.inputTokens += add.inputTokens;
2236
+ next.outputTokens += add.outputTokens;
2237
+ if (add.cacheCreationTokens) {
2238
+ next.cacheCreationTokens = (next.cacheCreationTokens ?? 0) + add.cacheCreationTokens;
2239
+ }
2240
+ if (add.cacheReadTokens) {
2241
+ next.cacheReadTokens = (next.cacheReadTokens ?? 0) + add.cacheReadTokens;
2242
+ }
2243
+ return next;
2244
+ }
2245
+ /** One provider call's usage as a single-attempt discard record. */
2246
+ discardedAttemptFrom(usage) {
2247
+ const detailed = (usage ?? { inputTokens: 0, outputTokens: 0 });
2248
+ return {
2249
+ attempts: 1,
2250
+ inputTokens: detailed.inputTokens ?? 0,
2251
+ outputTokens: detailed.outputTokens ?? 0,
2252
+ ...(detailed.cacheCreationTokens ? { cacheCreationTokens: detailed.cacheCreationTokens } : {}),
2253
+ ...(detailed.cacheReadTokens ? { cacheReadTokens: detailed.cacheReadTokens } : {}),
2254
+ };
2255
+ }
2256
+ /** Price the discarded spend so a caller can read it without re-deriving. */
2257
+ pricedDiscardedAttempts(discarded, model) {
2258
+ if (!discarded)
2259
+ return undefined;
2260
+ const estimatedCost = this.estimateCost(discarded, model);
2261
+ return estimatedCost ? { ...discarded, estimatedCost } : discarded;
2262
+ }
1795
2263
  mapStopReason(providerReason) {
1796
2264
  switch (providerReason) {
1797
2265
  case 'end_turn':
@@ -1812,19 +2280,50 @@ export class Membrane {
1812
2280
  }
1813
2281
  }
1814
2282
  calculateCacheHitRatio(usage) {
1815
- const cacheRead = usage.cacheReadTokens ?? 0;
1816
- const total = usage.inputTokens ?? 0;
1817
- if (total === 0)
1818
- return 0;
1819
- return cacheRead / total;
2283
+ return calculateCacheHitRatio(usage);
1820
2284
  }
1821
- resolvePricing(model) {
1822
- return this.registry?.getPricing(model) ?? getDefaultPricing(model);
2285
+ /**
2286
+ * Pricing is resolved on TWO axes, and SOURCE outranks SPECIFICITY:
2287
+ *
2288
+ * registry[served] → registry[requested] → builtin[served] → builtin[requested]
2289
+ *
2290
+ * Specificity — preferring the model that ACTUALLY served over the id that
2291
+ * was requested — is real: an alias or an auto-routed request otherwise
2292
+ * prices against a string the provider already replaced, and a live
2293
+ * 2026-08-25 call asking for `gpt-4o-mini` was served by
2294
+ * `gpt-4o-mini-2024-07-18`. But it only breaks ties WITHIN one source.
2295
+ * A configured `ModelRegistry` is the caller stating their own rates —
2296
+ * account-specific, negotiated, authoritative; the built-in table is
2297
+ * membrane's shipped guess at public list prices. Merging the two per-model
2298
+ * (`registry[served] ?? builtin[served]`, return on the first hit) let the
2299
+ * guess for a snapshot outrank the caller's own entry for the alias they
2300
+ * asked for, so a caller who prices their alias and lets the provider pick
2301
+ * the snapshot was billed at membrane's number instead of theirs.
2302
+ *
2303
+ * Both fallbacks stay: the served model may be absent from a source, and the
2304
+ * provider may name none at all.
2305
+ */
2306
+ resolvePricing(requestedModel, actualModel) {
2307
+ const servedModel = actualModel && actualModel !== requestedModel ? actualModel : undefined;
2308
+ const fromRegistry = (modelId) => modelId === undefined ? undefined : this.registry?.getPricing(modelId);
2309
+ const fromBuiltin = (modelId) => modelId === undefined ? undefined : getDefaultPricing(modelId);
2310
+ return fromRegistry(servedModel)
2311
+ ?? fromRegistry(requestedModel)
2312
+ ?? fromBuiltin(servedModel)
2313
+ ?? fromBuiltin(requestedModel);
1823
2314
  }
1824
2315
  /** Resolve pricing + calculate cost in one call (for one-shot use outside loops). */
1825
- estimateCost(usage, model) {
1826
- const pricing = this.resolvePricing(model);
1827
- return pricing ? calculateCost(usage, pricing) : undefined;
2316
+ estimateCost(usage, requestedModel, actualModel) {
2317
+ const pricing = this.resolvePricing(requestedModel, actualModel);
2318
+ if (!pricing) {
2319
+ // An absent cost and a zero cost are different claims. Returning
2320
+ // undefined says "membrane does not know what this costs"; saying it out
2321
+ // loud once per model keeps that from reading as "free" to a caller that
2322
+ // only ever sees the omission.
2323
+ warnUnpricedModel(actualModel || requestedModel);
2324
+ return undefined;
2325
+ }
2326
+ return calculateCost(usage, pricing);
1828
2327
  }
1829
2328
  calculateRetryDelay(attempt, overloaded = false) {
1830
2329
  const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = overloaded
@@ -1858,6 +2357,11 @@ export class Membrane {
1858
2357
  * Check if an error is an abort error
1859
2358
  */
1860
2359
  isAbortError(error) {
2360
+ // An adapter's own deadline: a timeout by classification, still an abort
2361
+ // by provenance, so the streaming paths hand back the partial content
2362
+ // they collected instead of throwing.
2363
+ if (isTimeoutAbortError(error))
2364
+ return true;
1861
2365
  if (error instanceof Error) {
1862
2366
  // Standard AbortError
1863
2367
  if (error.name === 'AbortError')
@@ -1872,6 +2376,21 @@ export class Membrane {
1872
2376
  }
1873
2377
  return false;
1874
2378
  }
2379
+ /**
2380
+ * Why a caught abort happened. The caller's own signal is authoritative:
2381
+ * if it fired, the cancellation is theirs whatever the error text says.
2382
+ * Otherwise an adapter-side deadline classifies as a timeout — the adapters
2383
+ * mark the abort createCombinedSignal's timeoutMs raises and map it to a
2384
+ * TimeoutAbortError, so the identity survives their error handling — and
2385
+ * anything else that reached the abort catch is a failure, not a person.
2386
+ */
2387
+ abortReason(error, signal) {
2388
+ if (signal?.aborted)
2389
+ return 'user';
2390
+ if (classifyError(error).type === 'timeout')
2391
+ return 'timeout';
2392
+ return 'error';
2393
+ }
1875
2394
  /**
1876
2395
  * Build an AbortedResponse from current execution state
1877
2396
  */
@@ -1921,7 +2440,12 @@ export class Membrane {
1921
2440
  * ```
1922
2441
  */
1923
2442
  streamYielding(request, options = {}) {
1924
- const toolMode = this.resolveToolMode(request);
2443
+ // YieldingStreamOptions carries no per-request formatter override, so the
2444
+ // selection here can only land on the instance formatter — it goes through
2445
+ // resolveActiveFormatter anyway so this path reads the same single source
2446
+ // as complete() and stream() if an override is ever added.
2447
+ const activeFormatter = this.resolveActiveFormatter();
2448
+ const toolMode = this.resolveToolMode(request, activeFormatter);
1925
2449
  // refusalRetries is implemented on the native path only. The XML path
1926
2450
  // accumulates into a streaming parser carrying prefill context and
1927
2451
  // resumption depths; rolling that back mid-turn is a separate problem,
@@ -1933,14 +2457,14 @@ export class Membrane {
1933
2457
  }
1934
2458
  // Create the yielding stream with the appropriate inference runner
1935
2459
  const runInference = toolMode === 'native'
1936
- ? (stream) => this.runNativeToolsYielding(request, options, stream)
1937
- : (stream) => this.runXmlToolsYielding(request, options, stream);
2460
+ ? (stream) => this.runNativeToolsYielding(request, options, stream, activeFormatter)
2461
+ : (stream) => this.runXmlToolsYielding(request, options, stream, activeFormatter);
1938
2462
  return new YieldingStreamImpl(options, runInference);
1939
2463
  }
1940
2464
  /**
1941
2465
  * Run XML-based tool execution with yielding stream.
1942
2466
  */
1943
- async runXmlToolsYielding(request, options, stream) {
2467
+ async runXmlToolsYielding(request, options, stream, activeFormatter = this.resolveActiveFormatter()) {
1944
2468
  const startTime = Date.now();
1945
2469
  const { maxToolDepth: maxToolDepthOpt, emitTokens = true, emitBlocks = true, emitUsage = true, } = options;
1946
2470
  // Yielding paths default to unlimited (the caller — typically an agent
@@ -1972,14 +2496,20 @@ export class Membrane {
1972
2496
  let enteredViaResumption = false;
1973
2497
  let prevRoundStopSequence;
1974
2498
  const warnLog = this.config.logger ?? console;
1975
- // Initialize parser from formatter for format-specific tracking
1976
- const formatter = this.formatter;
2499
+ // Initialize parser from the formatter streamYielding selected, so the
2500
+ // parser and the build below read the same format.
2501
+ const formatter = activeFormatter;
1977
2502
  const parser = formatter.createStreamParser();
1978
2503
  let toolDepth = 0;
2504
+ // Honest turn telemetry: provider calls actually made (including refusal
2505
+ // re-issues inside streamOnce) and continuation rounds.
2506
+ let providerCalls = 0;
2507
+ let rounds = 0;
1979
2508
  // Once-per-stream latch for the injectedMessages-unsupported warning.
1980
2509
  let warnedInjectionUnsupported = false;
1981
- let totalUsage = { inputTokens: 0, outputTokens: 0 };
1982
- const pricing = this.resolvePricing(request.config.model);
2510
+ // See streamWithXmlTools: one accumulator per turn, pricing each round
2511
+ // under the model that served it.
2512
+ const turnUsage = new TurnUsageAccumulator(request.config.model, (servedModel) => this.resolvePricing(request.config.model, servedModel));
1983
2513
  const contentBlocks = [];
1984
2514
  let lastStopReason = 'end_turn';
1985
2515
  let lastStopSequence;
@@ -2021,13 +2551,13 @@ export class Membrane {
2021
2551
  resumptionRounds++;
2022
2552
  if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
2023
2553
  warnLog.warn(`[membrane] automatic resumption at round ${resumptionRounds} ` +
2024
- `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
2554
+ `(${turnUsage.total.inputTokens} input tokens so far this turn) — ` +
2025
2555
  `a spin shows up here before it shows up on the bill`);
2026
2556
  }
2027
2557
  if (resumptionRounds > maxResumptionRounds) {
2028
2558
  warnLog.warn(`[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
2029
2559
  `ending turn with stopReason 'round_limit'. ` +
2030
- `${totalUsage.inputTokens} input tokens spent this turn.`);
2560
+ `${turnUsage.total.inputTokens} input tokens spent this turn.`);
2031
2561
  return false;
2032
2562
  }
2033
2563
  return true;
@@ -2110,6 +2640,8 @@ export class Membrane {
2110
2640
  wrapThinkingTags: true,
2111
2641
  onRequest: (req) => { rawRequest = req; },
2112
2642
  });
2643
+ rounds++;
2644
+ providerCalls += streamResult.providerCalls;
2113
2645
  // If we detected stop sequence manually, fix up the parser and result
2114
2646
  if (detectedStopSequence && truncatedAccumulated !== null) {
2115
2647
  parser.reset();
@@ -2124,19 +2656,10 @@ export class Membrane {
2124
2656
  rawResponse = streamResult.raw;
2125
2657
  lastStopReason = this.mapStopReason(streamResult.stopReason);
2126
2658
  lastStopSequence = streamResult.stopSequence ?? undefined;
2127
- // Accumulate usage (including cache metrics)
2128
- totalUsage.inputTokens += streamResult.usage.inputTokens;
2129
- totalUsage.outputTokens += streamResult.usage.outputTokens;
2130
- if (streamResult.usage.cacheCreationTokens) {
2131
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
2132
- }
2133
- if (streamResult.usage.cacheReadTokens) {
2134
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
2135
- }
2136
- if (pricing)
2137
- totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
2659
+ // Accumulate usage (including cache metrics), priced at this round's model
2660
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
2138
2661
  if (emitUsage) {
2139
- stream.emit({ type: 'usage', usage: { ...totalUsage } });
2662
+ stream.emit({ type: 'usage', usage: usageSoFar });
2140
2663
  }
2141
2664
  // Flush the parser
2142
2665
  const flushResult = parser.flush();
@@ -2159,7 +2682,7 @@ export class Membrane {
2159
2682
  warnLog.warn(`[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
2160
2683
  `made no progress (${streamedThisRound} chars this round, stop ` +
2161
2684
  `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
2162
- `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`);
2685
+ `stopReason 'no_progress'. ${turnUsage.total.inputTokens} input tokens spent this turn.`);
2163
2686
  lastStopReason = 'no_progress';
2164
2687
  break;
2165
2688
  }
@@ -2407,9 +2930,10 @@ export class Membrane {
2407
2930
  // Build final response
2408
2931
  const fullAccumulated = parser.getAccumulated();
2409
2932
  const newContent = fullAccumulated.slice(initialPrefillLength);
2410
- const response = this.buildFinalResponse(newContent, contentBlocks, lastStopReason, totalUsage, request, prefillResult, startTime, 1, rawRequest, rawResponse, executedToolCalls, executedToolResults, initialBlockType, lastStopSequence);
2933
+ const response = this.buildFinalResponse(newContent, contentBlocks, lastStopReason, turnUsage, request, prefillResult, startTime, providerCalls, rawRequest, rawResponse, executedToolCalls, executedToolResults, initialBlockType, lastStopSequence);
2411
2934
  // Merge provider thinking signatures into parser-derived thinking blocks
2412
2935
  this.mergeProviderThinkingBlocks(response.content, providerThinkingBlocks);
2936
+ response.details.timing.rounds = rounds;
2413
2937
  stream.emit({ type: 'complete', response });
2414
2938
  }
2415
2939
  catch (error) {
@@ -2418,7 +2942,7 @@ export class Membrane {
2418
2942
  const newContent = fullAccumulated.slice(initialPrefillLength);
2419
2943
  stream.emit({
2420
2944
  type: 'aborted',
2421
- reason: 'user',
2945
+ reason: this.abortReason(error, stream.signal),
2422
2946
  partialContent: parseAccumulatedIntoBlocks(newContent).blocks,
2423
2947
  rawAssistantText: newContent,
2424
2948
  toolCalls: executedToolCalls,
@@ -2433,7 +2957,7 @@ export class Membrane {
2433
2957
  /**
2434
2958
  * Run native tool execution with yielding stream.
2435
2959
  */
2436
- async runNativeToolsYielding(request, options, stream) {
2960
+ async runNativeToolsYielding(request, options, stream, activeFormatter = this.resolveActiveFormatter()) {
2437
2961
  const startTime = Date.now();
2438
2962
  const { maxToolDepth: maxToolDepthOpt, emitTokens = true, emitBlocks = true, emitUsage = true, } = options;
2439
2963
  // Yielding paths default to unlimited (the caller — typically an agent
@@ -2445,8 +2969,13 @@ export class Membrane {
2445
2969
  ? Infinity
2446
2970
  : maxToolDepthOpt;
2447
2971
  let toolDepth = 0;
2448
- let totalUsage = { inputTokens: 0, outputTokens: 0 };
2449
- const pricing = this.resolvePricing(request.config.model);
2972
+ // See streamWithXmlTools: one accumulator per turn, pricing each round
2973
+ // under the model that served it.
2974
+ const turnUsage = new TurnUsageAccumulator(request.config.model, (servedModel) => this.resolvePricing(request.config.model, servedModel));
2975
+ // Honest turn telemetry: provider calls actually made (including refusal
2976
+ // re-issues inside streamOnce) and continuation rounds.
2977
+ let providerCalls = 0;
2978
+ let rounds = 0;
2450
2979
  let lastStopReason = 'end_turn';
2451
2980
  let lastStopSequence;
2452
2981
  let rawRequest;
@@ -2454,8 +2983,11 @@ export class Membrane {
2454
2983
  let allTextAccumulated = '';
2455
2984
  const executedToolCalls = [];
2456
2985
  const executedToolResults = [];
2986
+ // Spend on refusal attempts this turn threw away (see streamOnce).
2987
+ let discardedUsage;
2457
2988
  let messages = [...request.messages];
2458
2989
  let allContentBlocks = [];
2990
+ let markersInLastRequest = 0;
2459
2991
  try {
2460
2992
  // Tool execution loop
2461
2993
  while (toolDepth <= maxToolDepth) {
@@ -2471,26 +3003,17 @@ export class Membrane {
2471
3003
  return;
2472
3004
  }
2473
3005
  // Build provider request with native tools
2474
- const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0);
3006
+ const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0, activeFormatter);
2475
3007
  // Stream from provider
2476
3008
  let textAccumulated = '';
2477
- let blockIndex = 0;
2478
3009
  // Where this attempt starts inside the tool-loop-spanning buffer, so
2479
3010
  // a refusal retry can roll back exactly this attempt's contribution.
2480
3011
  const allTextBefore = allTextAccumulated.length;
2481
- // Track block-type from the provider's content_block_start signal so
3012
+ // Track block-type from the provider's content_block signals so
2482
3013
  // every token chunk is tagged with the membrane block it belongs to.
2483
3014
  // Without this, thinking_delta chunks get mislabelled as 'text' and
2484
3015
  // downstream consumers (TUIs, WebUIs) can't render them distinctly.
2485
- let currentBlockType = 'text';
2486
- const seenBlockIndices = new Set();
2487
- const mapApiBlockType = (apiType) => {
2488
- if (apiType === 'thinking')
2489
- return 'thinking';
2490
- if (apiType === 'tool_use')
2491
- return 'tool_call';
2492
- return 'text';
2493
- };
3016
+ const tracker = new NativeBlockTracker(emitBlocks ? (event) => stream.emit({ type: 'block', event }) : undefined);
2494
3017
  const streamResult = await this.streamOnce(providerRequest, {
2495
3018
  onChunk: (chunk) => {
2496
3019
  if (stream.isCancelled)
@@ -2499,9 +3022,9 @@ export class Membrane {
2499
3022
  allTextAccumulated += chunk;
2500
3023
  if (emitTokens) {
2501
3024
  const meta = {
2502
- type: currentBlockType,
2503
- visible: currentBlockType === 'text',
2504
- blockIndex,
3025
+ type: tracker.currentType,
3026
+ visible: tracker.currentType === 'text',
3027
+ blockIndex: tracker.blockIndex,
2505
3028
  };
2506
3029
  stream.emit({ type: 'tokens', content: chunk, meta });
2507
3030
  }
@@ -2509,41 +3032,7 @@ export class Membrane {
2509
3032
  onContentBlock: (index, block) => {
2510
3033
  if (stream.isCancelled)
2511
3034
  return;
2512
- const apiType = block?.type;
2513
- const mbType = mapApiBlockType(apiType);
2514
- const isStart = !seenBlockIndices.has(index);
2515
- if (isStart) {
2516
- seenBlockIndices.add(index);
2517
- currentBlockType = mbType;
2518
- blockIndex = index;
2519
- if (emitBlocks) {
2520
- stream.emit({
2521
- type: 'block',
2522
- event: { event: 'block_start', index, block: { type: mbType } },
2523
- });
2524
- }
2525
- }
2526
- else if (emitBlocks) {
2527
- // Second call for the same index = content_block_stop. The
2528
- // provider has filled the block with final content; surface
2529
- // a block_complete with the relevant fields for consumers
2530
- // that want full block payloads (e.g. context-manager).
2531
- const apiBlock = block;
2532
- const mb = { type: mbType };
2533
- if (mbType === 'text')
2534
- mb.content = apiBlock?.text;
2535
- else if (mbType === 'thinking')
2536
- mb.content = apiBlock?.thinking;
2537
- else if (mbType === 'tool_call') {
2538
- mb.toolId = apiBlock?.id;
2539
- mb.toolName = apiBlock?.name;
2540
- mb.input = apiBlock?.input;
2541
- }
2542
- stream.emit({
2543
- type: 'block',
2544
- event: { event: 'block_complete', index, block: mb },
2545
- });
2546
- }
3035
+ tracker.onProviderBlock(index, block);
2547
3036
  },
2548
3037
  }, {
2549
3038
  signal: stream.signal,
@@ -2551,6 +3040,14 @@ export class Membrane {
2551
3040
  idleTimeoutMs: options.idleTimeoutMs,
2552
3041
  normalizedRequest: request,
2553
3042
  onRequest: (req) => { rawRequest = req; },
3043
+ // Telemetry reports what this request actually SHIPPED with —
3044
+ // builder breakpoints, stale passthrough, fallback, float, plus
3045
+ // whatever the beforeRequest hook and the wire clamp did after
3046
+ // the build. Both native paths used to hardcode 0, and counting
3047
+ // at build time reported a number no request ever had.
3048
+ onWireCacheMarkers: (markerCount) => {
3049
+ markersInLastRequest = markerCount;
3050
+ },
2554
3051
  refusalRetries: options.refusalRetries,
2555
3052
  // Discard the refused attempt: roll the accumulators back to
2556
3053
  // where this attempt began and tell the consumer to drop what it
@@ -2559,9 +3056,7 @@ export class Membrane {
2559
3056
  onRetrying: (info) => {
2560
3057
  allTextAccumulated = allTextAccumulated.slice(0, allTextBefore);
2561
3058
  textAccumulated = '';
2562
- blockIndex = 0;
2563
- currentBlockType = 'text';
2564
- seenBlockIndices.clear();
3059
+ tracker.reset();
2565
3060
  stream.emit({
2566
3061
  type: 'retrying',
2567
3062
  attempt: info.attempt,
@@ -2571,22 +3066,21 @@ export class Membrane {
2571
3066
  });
2572
3067
  },
2573
3068
  });
3069
+ // Single-callback adapters (OpenAI Responses) report each finalised
3070
+ // block once, after the stream: complete whatever never saw a stop.
3071
+ tracker.flush();
3072
+ rounds++;
3073
+ providerCalls += streamResult.providerCalls;
2574
3074
  rawResponse = streamResult.raw;
2575
3075
  lastStopReason = this.mapStopReason(streamResult.stopReason);
2576
3076
  lastStopSequence = streamResult.stopSequence ?? undefined;
2577
- // Accumulate usage (including cache metrics)
2578
- totalUsage.inputTokens += streamResult.usage.inputTokens;
2579
- totalUsage.outputTokens += streamResult.usage.outputTokens;
2580
- if (streamResult.usage.cacheCreationTokens) {
2581
- totalUsage.cacheCreationTokens = (totalUsage.cacheCreationTokens ?? 0) + streamResult.usage.cacheCreationTokens;
2582
- }
2583
- if (streamResult.usage.cacheReadTokens) {
2584
- totalUsage.cacheReadTokens = (totalUsage.cacheReadTokens ?? 0) + streamResult.usage.cacheReadTokens;
2585
- }
2586
- if (pricing)
2587
- totalUsage.estimatedCost = calculateCost(totalUsage, pricing);
3077
+ // Attempts this round re-issued past a refusal are billed calls whose
3078
+ // output was discarded — carry their spend to the final response.
3079
+ discardedUsage = this.mergeDiscardedAttempts(discardedUsage, streamResult.discardedUsage);
3080
+ // Accumulate usage (including cache metrics), priced at this round's model
3081
+ const usageSoFar = turnUsage.addRound(streamResult.model, streamResult.usage);
2588
3082
  if (emitUsage) {
2589
- stream.emit({ type: 'usage', usage: { ...totalUsage } });
3083
+ stream.emit({ type: 'usage', usage: usageSoFar });
2590
3084
  }
2591
3085
  // Parse content blocks from response
2592
3086
  const responseBlocks = this.parseProviderContent(streamResult.content);
@@ -2672,6 +3166,7 @@ export class Membrane {
2672
3166
  break;
2673
3167
  }
2674
3168
  const durationMs = Date.now() - startTime;
3169
+ const totalUsage = turnUsage.total;
2675
3170
  const response = {
2676
3171
  content: allContentBlocks,
2677
3172
  rawAssistantText: allTextAccumulated,
@@ -2685,18 +3180,25 @@ export class Membrane {
2685
3180
  triggeredSequence: lastStopSequence,
2686
3181
  wasTruncated: lastStopReason === 'max_tokens',
2687
3182
  },
2688
- usage: { ...totalUsage },
3183
+ usage: {
3184
+ ...totalUsage,
3185
+ ...(discardedUsage
3186
+ ? { discardedAttempts: this.pricedDiscardedAttempts(discardedUsage, request.config.model) }
3187
+ : {}),
3188
+ },
2689
3189
  timing: {
2690
3190
  totalDurationMs: durationMs,
2691
- attempts: 1,
3191
+ attempts: providerCalls,
3192
+ rounds,
2692
3193
  },
2693
3194
  model: {
2694
3195
  requested: request.config.model,
2695
- actual: request.config.model,
3196
+ actual: turnUsage.lastServedModel || request.config.model,
2696
3197
  provider: this.adapter.name,
3198
+ perRound: turnUsage.perRound,
2697
3199
  },
2698
3200
  cache: {
2699
- markersInRequest: 0,
3201
+ markersInRequest: markersInLastRequest,
2700
3202
  tokensCreated: totalUsage.cacheCreationTokens ?? 0,
2701
3203
  tokensRead: totalUsage.cacheReadTokens ?? 0,
2702
3204
  hitRatio: this.calculateCacheHitRatio(totalUsage),
@@ -2713,7 +3215,7 @@ export class Membrane {
2713
3215
  if (this.isAbortError(error)) {
2714
3216
  stream.emit({
2715
3217
  type: 'aborted',
2716
- reason: 'user',
3218
+ reason: this.abortReason(error, stream.signal),
2717
3219
  rawAssistantText: allTextAccumulated,
2718
3220
  toolCalls: executedToolCalls,
2719
3221
  toolResults: executedToolResults,