@animalabs/membrane 0.5.80 → 0.5.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/cache-wire-receipt.d.ts +13 -0
  2. package/dist/cache-wire-receipt.d.ts.map +1 -0
  3. package/dist/cache-wire-receipt.js +108 -0
  4. package/dist/cache-wire-receipt.js.map +1 -0
  5. package/dist/context/index.d.ts +1 -1
  6. package/dist/context/index.d.ts.map +1 -1
  7. package/dist/context/index.js +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/context/process.d.ts +3 -1
  10. package/dist/context/process.d.ts.map +1 -1
  11. package/dist/context/process.js +230 -49
  12. package/dist/context/process.js.map +1 -1
  13. package/dist/context/types.d.ts +30 -0
  14. package/dist/context/types.d.ts.map +1 -1
  15. package/dist/context/types.js +16 -0
  16. package/dist/context/types.js.map +1 -1
  17. package/dist/floating-cache-marker.test.js +21 -0
  18. package/dist/floating-cache-marker.test.js.map +1 -1
  19. package/dist/formatters/anthropic-xml.d.ts +2 -0
  20. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  21. package/dist/formatters/anthropic-xml.js +64 -23
  22. package/dist/formatters/anthropic-xml.js.map +1 -1
  23. package/dist/formatters/native.d.ts.map +1 -1
  24. package/dist/formatters/native.js +16 -3
  25. package/dist/formatters/native.js.map +1 -1
  26. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  27. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  28. package/dist/formatters/normalize-tool-pairs.js +45 -0
  29. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  30. package/dist/formatters/types.d.ts +31 -0
  31. package/dist/formatters/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/membrane.d.ts +137 -8
  37. package/dist/membrane.d.ts.map +1 -1
  38. package/dist/membrane.js +823 -310
  39. package/dist/membrane.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts +37 -0
  41. package/dist/providers/anthropic.d.ts.map +1 -1
  42. package/dist/providers/anthropic.js +142 -25
  43. package/dist/providers/anthropic.js.map +1 -1
  44. package/dist/providers/bedrock.d.ts +8 -0
  45. package/dist/providers/bedrock.d.ts.map +1 -1
  46. package/dist/providers/bedrock.js +24 -1
  47. package/dist/providers/bedrock.js.map +1 -1
  48. package/dist/providers/gemini.d.ts +11 -0
  49. package/dist/providers/gemini.d.ts.map +1 -1
  50. package/dist/providers/gemini.js +104 -82
  51. package/dist/providers/gemini.js.map +1 -1
  52. package/dist/providers/index.d.ts +1 -1
  53. package/dist/providers/index.d.ts.map +1 -1
  54. package/dist/providers/index.js.map +1 -1
  55. package/dist/providers/mock.d.ts +2 -0
  56. package/dist/providers/mock.d.ts.map +1 -1
  57. package/dist/providers/mock.js +2 -0
  58. package/dist/providers/mock.js.map +1 -1
  59. package/dist/providers/openai-compatible.d.ts +7 -0
  60. package/dist/providers/openai-compatible.d.ts.map +1 -1
  61. package/dist/providers/openai-compatible.js +99 -48
  62. package/dist/providers/openai-compatible.js.map +1 -1
  63. package/dist/providers/openai-completions.d.ts +5 -0
  64. package/dist/providers/openai-completions.d.ts.map +1 -1
  65. package/dist/providers/openai-completions.js +107 -44
  66. package/dist/providers/openai-completions.js.map +1 -1
  67. package/dist/providers/openai-responses-api.d.ts +16 -0
  68. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  69. package/dist/providers/openai-responses-api.js +38 -10
  70. package/dist/providers/openai-responses-api.js.map +1 -1
  71. package/dist/providers/openai-responses.d.ts +2 -0
  72. package/dist/providers/openai-responses.d.ts.map +1 -1
  73. package/dist/providers/openai-responses.js +8 -1
  74. package/dist/providers/openai-responses.js.map +1 -1
  75. package/dist/providers/openai.d.ts +7 -0
  76. package/dist/providers/openai.d.ts.map +1 -1
  77. package/dist/providers/openai.js +92 -44
  78. package/dist/providers/openai.js.map +1 -1
  79. package/dist/providers/openrouter.d.ts +9 -0
  80. package/dist/providers/openrouter.d.ts.map +1 -1
  81. package/dist/providers/openrouter.js +118 -62
  82. package/dist/providers/openrouter.js.map +1 -1
  83. package/dist/providers/utils.d.ts +74 -2
  84. package/dist/providers/utils.d.ts.map +1 -1
  85. package/dist/providers/utils.js +184 -3
  86. package/dist/providers/utils.js.map +1 -1
  87. package/dist/registry/default-pricing.d.ts +22 -0
  88. package/dist/registry/default-pricing.d.ts.map +1 -1
  89. package/dist/registry/default-pricing.js +162 -28
  90. package/dist/registry/default-pricing.js.map +1 -1
  91. package/dist/types/content.d.ts +13 -0
  92. package/dist/types/content.d.ts.map +1 -1
  93. package/dist/types/content.js.map +1 -1
  94. package/dist/types/errors.d.ts +15 -0
  95. package/dist/types/errors.d.ts.map +1 -1
  96. package/dist/types/errors.js +26 -0
  97. package/dist/types/errors.js.map +1 -1
  98. package/dist/types/index.d.ts +3 -3
  99. package/dist/types/index.d.ts.map +1 -1
  100. package/dist/types/index.js +1 -1
  101. package/dist/types/index.js.map +1 -1
  102. package/dist/types/provider.d.ts +50 -0
  103. package/dist/types/provider.d.ts.map +1 -1
  104. package/dist/types/request.d.ts +6 -0
  105. package/dist/types/request.d.ts.map +1 -1
  106. package/dist/types/response.d.ts +85 -4
  107. package/dist/types/response.d.ts.map +1 -1
  108. package/dist/types/response.js.map +1 -1
  109. package/dist/types/streaming.d.ts +6 -1
  110. package/dist/types/streaming.d.ts.map +1 -1
  111. package/dist/utils/cache-marker-budget.d.ts +91 -0
  112. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  113. package/dist/utils/cache-marker-budget.js +208 -0
  114. package/dist/utils/cache-marker-budget.js.map +1 -0
  115. package/dist/utils/cost.d.ts +22 -0
  116. package/dist/utils/cost.d.ts.map +1 -1
  117. package/dist/utils/cost.js +55 -0
  118. package/dist/utils/cost.js.map +1 -1
  119. package/dist/utils/thinking-carriers.d.ts +80 -0
  120. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  121. package/dist/utils/thinking-carriers.js +115 -0
  122. package/dist/utils/thinking-carriers.js.map +1 -0
  123. package/dist/utils/tool-parser.d.ts +24 -0
  124. package/dist/utils/tool-parser.d.ts.map +1 -1
  125. package/dist/utils/tool-parser.js +367 -161
  126. package/dist/utils/tool-parser.js.map +1 -1
  127. package/dist/utils/usage.d.ts +71 -0
  128. package/dist/utils/usage.d.ts.map +1 -0
  129. package/dist/utils/usage.js +161 -0
  130. package/dist/utils/usage.js.map +1 -0
  131. package/dist/yielding-stream.d.ts +8 -0
  132. package/dist/yielding-stream.d.ts.map +1 -1
  133. package/dist/yielding-stream.js +49 -3
  134. package/dist/yielding-stream.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/cache-wire-receipt.ts +125 -0
  137. package/src/context/index.ts +1 -0
  138. package/src/context/process.ts +305 -59
  139. package/src/context/types.ts +38 -0
  140. package/src/floating-cache-marker.test.ts +24 -0
  141. package/src/formatters/anthropic-xml.ts +66 -23
  142. package/src/formatters/native.ts +23 -2
  143. package/src/formatters/normalize-tool-pairs.ts +49 -0
  144. package/src/formatters/types.ts +35 -0
  145. package/src/index.ts +6 -0
  146. package/src/membrane.ts +974 -313
  147. package/src/providers/anthropic.ts +173 -19
  148. package/src/providers/bedrock.ts +25 -1
  149. package/src/providers/gemini.ts +125 -87
  150. package/src/providers/index.ts +1 -0
  151. package/src/providers/mock.ts +3 -0
  152. package/src/providers/openai-compatible.ts +102 -45
  153. package/src/providers/openai-completions.ts +112 -46
  154. package/src/providers/openai-responses-api.ts +45 -15
  155. package/src/providers/openai-responses.ts +8 -1
  156. package/src/providers/openai.ts +95 -42
  157. package/src/providers/openrouter.ts +125 -63
  158. package/src/providers/utils.ts +227 -6
  159. package/src/registry/default-pricing.ts +165 -28
  160. package/src/types/content.ts +14 -0
  161. package/src/types/errors.ts +28 -0
  162. package/src/types/index.ts +6 -0
  163. package/src/types/provider.ts +55 -0
  164. package/src/types/request.ts +7 -0
  165. package/src/types/response.ts +109 -8
  166. package/src/types/streaming.ts +8 -2
  167. package/src/utils/cache-marker-budget.ts +237 -0
  168. package/src/utils/cost.ts +64 -0
  169. package/src/utils/thinking-carriers.ts +125 -0
  170. package/src/utils/tool-parser.ts +473 -174
  171. package/src/utils/usage.ts +184 -0
  172. package/src/yielding-stream.ts +54 -3
@@ -20,8 +20,10 @@ import {
20
20
  authError,
21
21
  serverError,
22
22
  abortError,
23
+ unsupportedError,
23
24
  } from '../types/index.js';
24
25
  import { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
26
+ import { assertTerminalEventObserved } from './utils.js';
25
27
  import { CacheKeepalive, type CacheKeepaliveConfig } from '../cache-keepalive.js';
26
28
 
27
29
  // ============================================================================
@@ -128,6 +130,18 @@ export function thinkingEnabled(request: ProviderRequest): boolean {
128
130
  // Adapter Configuration
129
131
  // ============================================================================
130
132
 
133
+ /**
134
+ * What the dynamicHeaders callback is told about the request it stamps.
135
+ * `lane` names the transport shape: 'stream' is the conversational turn loop,
136
+ * 'complete' the non-streamed lane (compression, side-calls, keepalive
137
+ * touches). A stamp that describes WHY the agent's turn fired belongs on the
138
+ * stream lane only — a compression call running in the background is not the
139
+ * turn, and stamping it with the turn's cause would lie to the ledger.
140
+ */
141
+ export interface DynamicHeadersContext {
142
+ lane: 'stream' | 'complete';
143
+ }
144
+
131
145
  export interface AnthropicAdapterConfig {
132
146
  /** API key (defaults to ANTHROPIC_API_KEY env var) */
133
147
  apiKey?: string | null;
@@ -144,6 +158,18 @@ export interface AnthropicAdapterConfig {
144
158
 
145
159
  /** Default headers to include with Anthropic requests */
146
160
  defaultHeaders?: ClientOptions['defaultHeaders'];
161
+
162
+ /**
163
+ * Live per-request headers, evaluated at request time — for values that
164
+ * change between calls (e.g. household telemetry stamps such as
165
+ * `x-gate-debt-chunks`, read by an inference gateway and stripped there
166
+ * before the vendor ever sees them). Merged over the per-request beta
167
+ * headers for the OUTGOING request only; cache-keepalive replays
168
+ * deliberately resend their recorded headers, so a telemetry stamp is
169
+ * never replayed stale — an unstamped touch is honest, a stale stamp lies.
170
+ * null/undefined/'' values are dropped.
171
+ */
172
+ dynamicHeaders?: (ctx?: DynamicHeadersContext) => Record<string, string | number | null | undefined>;
147
173
 
148
174
  /** Default max tokens */
149
175
  defaultMaxTokens?: number;
@@ -164,6 +190,14 @@ export interface AnthropicAdapterConfig {
164
190
 
165
191
  export class AnthropicAdapter implements ProviderAdapter {
166
192
  readonly name = 'anthropic';
193
+
194
+ /**
195
+ * Verified live 2026-08-25 (claude-haiku-4-5, 4,650-token cached system
196
+ * prompt): call 1 returned input_tokens 8 / cache_creation_input_tokens 4650,
197
+ * call 2 input_tokens 8 / cache_read_input_tokens 4650. `input_tokens` never
198
+ * counts the cached span.
199
+ */
200
+ readonly usageCacheConvention = 'cache-excluded' as const;
167
201
  private client: Anthropic;
168
202
  private defaultMaxTokens: number;
169
203
  /** Any anthropic-beta value from defaultHeaders (e.g. the oauth beta for
@@ -173,6 +207,8 @@ export class AnthropicAdapter implements ProviderAdapter {
173
207
  private defaultBeta: string | undefined;
174
208
  /** Holds idle agents' cached prefixes warm; undefined when disabled. */
175
209
  readonly cacheKeepalive: CacheKeepalive | undefined;
210
+ /** Live per-request header source (see AnthropicAdapterConfig.dynamicHeaders). */
211
+ private readonly dynamicHeaders?: (ctx?: DynamicHeadersContext) => Record<string, string | number | null | undefined>;
176
212
 
177
213
  constructor(config: AnthropicAdapterConfig = {}) {
178
214
  const clientOptions: ClientOptions = {
@@ -180,6 +216,7 @@ export class AnthropicAdapter implements ProviderAdapter {
180
216
  defaultHeaders: config.defaultHeaders,
181
217
  };
182
218
  this.defaultBeta = extractBetaHeader(config.defaultHeaders);
219
+ this.dynamicHeaders = config.dynamicHeaders;
183
220
 
184
221
  if (config.authToken !== undefined) {
185
222
  clientOptions.authToken = config.authToken;
@@ -225,7 +262,7 @@ export class AnthropicAdapter implements ProviderAdapter {
225
262
  try {
226
263
  const response = await this.client.messages.create(fullRequest, {
227
264
  signal: options?.signal,
228
- headers,
265
+ headers: this.liveHeaders(headers, 'complete'),
229
266
  });
230
267
 
231
268
  return this.parseResponse(response, fullRequest);
@@ -298,7 +335,7 @@ export class AnthropicAdapter implements ProviderAdapter {
298
335
  try {
299
336
  const stream = await this.client.messages.stream(anthropicRequest, {
300
337
  signal: idleAbort.signal,
301
- headers: this.betaHeaders(request),
338
+ headers: this.liveHeaders(this.betaHeaders(request), 'stream'),
302
339
  });
303
340
 
304
341
  // Accumulate response metadata from SSE events directly, so we can
@@ -314,6 +351,7 @@ export class AnthropicAdapter implements ProviderAdapter {
314
351
  let inferenceGeo: string | undefined;
315
352
  let serviceTier: string | undefined;
316
353
  let stopReason: string = 'end_turn';
354
+ let sawTerminalEvent = false;
317
355
  let stopSequence: string | undefined;
318
356
  let stopDetails: unknown;
319
357
 
@@ -329,6 +367,43 @@ export class AnthropicAdapter implements ProviderAdapter {
329
367
  // thinking blocks with no thinking_delta at all (signature only).
330
368
  const wrapThinkingTags = options?.wrapThinkingTags === true;
331
369
  let thinkingTagOpen = false;
370
+ // Index of a block that has started and not yet been stopped. A stream
371
+ // can end with one still open: measured live against claude-haiku-4-5
372
+ // (2026-08-25), a tool call truncated by max_tokens emits
373
+ // content_block_start + input_json_delta fragments and then goes
374
+ // straight to message_delta — no content_block_stop at all.
375
+ let openBlockIndex = -1;
376
+
377
+ const finalizeContentBlock = (blockIdx: number, sawBlockStop: boolean): void => {
378
+ const block = contentBlocks[blockIdx];
379
+ if (block) {
380
+ if (block.type === 'text') {
381
+ block.text = currentBlockContent;
382
+ } else if (block.type === 'thinking') {
383
+ block.thinking = currentBlockContent;
384
+ if (thinkingTagOpen) {
385
+ callbacks.onChunk('</thinking>\n');
386
+ thinkingTagOpen = false;
387
+ }
388
+ } else if (block.type === 'tool_use') {
389
+ if (!sawBlockStop) {
390
+ // Arguments never finished arriving, so `input` is still the
391
+ // empty object content_block_start carried — a plausible no-arg
392
+ // call that nothing downstream can distinguish from a real one.
393
+ block.unparseableInput = currentBlockInputJson;
394
+ } else if (currentBlockInputJson) {
395
+ try {
396
+ block.input = JSON.parse(currentBlockInputJson);
397
+ } catch {
398
+ // Same fabrication, reached by a block that did stop: keep the
399
+ // raw accumulation and mark it so consumers can refuse.
400
+ block.unparseableInput = currentBlockInputJson;
401
+ }
402
+ }
403
+ }
404
+ }
405
+ callbacks.onContentBlock?.(blockIdx, contentBlocks[blockIdx]);
406
+ };
332
407
 
333
408
  for await (const event of stream) {
334
409
  sawEvent = true;
@@ -354,6 +429,7 @@ export class AnthropicAdapter implements ProviderAdapter {
354
429
 
355
430
  } else if (event.type === 'content_block_start') {
356
431
  currentBlockIndex = event.index;
432
+ openBlockIndex = event.index;
357
433
  currentBlockContent = '';
358
434
  currentBlockInputJson = '';
359
435
  contentBlocks[currentBlockIndex] = { ...event.content_block };
@@ -387,22 +463,8 @@ export class AnthropicAdapter implements ProviderAdapter {
387
463
 
388
464
  } else if (event.type === 'content_block_stop') {
389
465
  // Finalize block — use event.index for defensive correctness
390
- const blockIdx = (event as { index: number }).index;
391
- const block = contentBlocks[blockIdx];
392
- if (block) {
393
- if (block.type === 'text') {
394
- block.text = currentBlockContent;
395
- } else if (block.type === 'thinking') {
396
- block.thinking = currentBlockContent;
397
- if (thinkingTagOpen) {
398
- callbacks.onChunk('</thinking>\n');
399
- thinkingTagOpen = false;
400
- }
401
- } else if (block.type === 'tool_use' && currentBlockInputJson) {
402
- try { block.input = JSON.parse(currentBlockInputJson); } catch { /* partial JSON */ }
403
- }
404
- }
405
- callbacks.onContentBlock?.(blockIdx, contentBlocks[blockIdx]);
466
+ finalizeContentBlock((event as { index: number }).index, true);
467
+ openBlockIndex = -1;
406
468
 
407
469
  } else if (event.type === 'message_delta') {
408
470
  // All content blocks are finalized by the time message_delta arrives.
@@ -414,6 +476,7 @@ export class AnthropicAdapter implements ProviderAdapter {
414
476
  stop_details?: unknown;
415
477
  };
416
478
  stopReason = delta.stop_reason ?? 'end_turn';
479
+ sawTerminalEvent = true;
417
480
  stopSequence = delta.stop_sequence ?? undefined;
418
481
  // stop_details carries refusal metadata (e.g., category: 'reasoning_extraction')
419
482
  stopDetails = delta.stop_details ?? undefined;
@@ -438,9 +501,22 @@ export class AnthropicAdapter implements ProviderAdapter {
438
501
  if (idleTimer) clearTimeout(idleTimer);
439
502
  options?.signal?.removeEventListener('abort', onExternalAbort);
440
503
 
504
+ // A block still open here never received its content_block_stop: the
505
+ // turn ended mid-block. Finalize it so the accumulated text is not lost
506
+ // and a truncated tool call is marked rather than persisted as `{}`.
507
+ if (openBlockIndex >= 0) {
508
+ finalizeContentBlock(openBlockIndex, false);
509
+ openBlockIndex = -1;
510
+ }
511
+
441
512
  // Force-close the HTTP connection so we don't block on SSE drain
442
513
  try { stream.controller.abort(); } catch { /* already closed */ }
443
514
 
515
+ // message_delta is this adapter's terminal event — the loop breaks on
516
+ // it. Falling out of the for-await without one means the SSE connection
517
+ // closed mid-turn, and stopReason is still its 'end_turn' initialiser.
518
+ assertTerminalEventObserved(sawTerminalEvent, 'Anthropic', fullRequest);
519
+
444
520
  return {
445
521
  content: contentBlocks,
446
522
  stopReason,
@@ -506,6 +582,19 @@ export class AnthropicAdapter implements ProviderAdapter {
506
582
  * the SDK replaces same-key defaults instead of merging, and the API
507
583
  * accepts comma-separated betas. Undefined when nothing to add, so the
508
584
  * defaults apply untouched. */
585
+ /** Base headers + the live dynamicHeaders stamp. Request time only: the
586
+ * keepalive recorder receives the base headers BEFORE this merge, so
587
+ * replayed touches never carry a stale telemetry value. */
588
+ private liveHeaders(base: Record<string, string> | undefined, lane: DynamicHeadersContext['lane']): Record<string, string> | undefined {
589
+ const dyn = this.dynamicHeaders?.({ lane });
590
+ if (!dyn) return base;
591
+ const out: Record<string, string> = { ...(base ?? {}) };
592
+ for (const [k, v] of Object.entries(dyn)) {
593
+ if (v !== null && v !== undefined && v !== '') out[k] = String(v);
594
+ }
595
+ return Object.keys(out).length ? out : undefined;
596
+ }
597
+
509
598
  private betaHeaders(request: ProviderRequest): Record<string, string> | undefined {
510
599
  if (!thinkingEnabled(request) || !needsInterleavedThinkingBeta(request.model)) {
511
600
  return undefined;
@@ -672,6 +761,10 @@ export class AnthropicAdapter implements ProviderAdapter {
672
761
  }
673
762
 
674
763
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
764
+ // Already-classified failures (e.g. the stream-integrity guards) keep
765
+ // their type and retryability instead of being re-derived from a string.
766
+ if (error instanceof MembraneError) return error;
767
+
675
768
  if (error instanceof Anthropic.APIError) {
676
769
  // Mid-stream SSE `error` events are rethrown by the SDK as APIError
677
770
  // with status === undefined (sdk core/streaming.js), so the HTTP
@@ -880,6 +973,23 @@ export function toAnthropicContent(blocks: ContentBlock[]): Anthropic.ContentBlo
880
973
  media_type: block.source.mediaType as 'application/pdf',
881
974
  data: block.source.data,
882
975
  },
976
+ // Anthropic's document block carries the filename as `title`; it was
977
+ // being dropped, so the model lost the one hint about what the PDF is.
978
+ ...(block.filename ? { title: block.filename } : {}),
979
+ });
980
+ break;
981
+
982
+ case 'generated_image':
983
+ // A provider-generated image is a base64 image with a MIME type, which
984
+ // is exactly Anthropic's image block — carrying it across costs nothing
985
+ // and lets an image Gemini produced re-enter Anthropic history.
986
+ result.push({
987
+ type: 'image',
988
+ source: {
989
+ type: 'base64',
990
+ media_type: block.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
991
+ data: block.data,
992
+ },
883
993
  });
884
994
  break;
885
995
 
@@ -919,12 +1029,53 @@ export function toAnthropicContent(blocks: ContentBlock[]): Anthropic.ContentBlo
919
1029
  data: (block as any).data,
920
1030
  } as any);
921
1031
  break;
1032
+
1033
+ default: {
1034
+ // The REQUEST path cannot degrade gracefully: a dropped block reaches
1035
+ // the model as an absence, and it answers about content it was never
1036
+ // shown. `audio` and `video` have no Anthropic Messages representation
1037
+ // at all, and a block type added later would silently join them. Fail
1038
+ // loudly at the boundary instead — the caller can strip or transcode.
1039
+ const unsupportedType = (block as { type?: string }).type ?? 'unknown';
1040
+ throw unsupportedError(
1041
+ `Anthropic has no representation for a "${unsupportedType}" content block, and dropping`
1042
+ + ' it would send the model a message missing content the caller supplied.'
1043
+ + ' Remove or convert the block before sending it on this provider.'
1044
+ );
1045
+ }
922
1046
  }
923
1047
  }
924
1048
 
925
1049
  return result;
926
1050
  }
927
1051
 
1052
+ /** Unrecognised response block types warn once each, not once per conversion. */
1053
+ const warnedUnconvertibleResponseBlocks = new Set<string>();
1054
+
1055
+ /**
1056
+ * The RESPONSE path can degrade gracefully where the request path cannot: the
1057
+ * provider's own block is in hand, so keeping it verbatim on a zero-width
1058
+ * carrier loses nothing recoverable and lets formatters replay it. Warn once
1059
+ * per type so a new provider block type surfaces without flooding the log.
1060
+ */
1061
+ function preserveUnconvertibleBlock(block: unknown, sourceLabel: string): ContentBlock {
1062
+ const blockType = (block as { type?: string })?.type ?? 'unknown';
1063
+ if (!warnedUnconvertibleResponseBlocks.has(blockType)) {
1064
+ warnedUnconvertibleResponseBlocks.add(blockType);
1065
+ console.warn(
1066
+ `[membrane:${sourceLabel}] no normalized ContentBlock for provider block type`
1067
+ + ` "${blockType}" — preserving it verbatim as a rawItem carrier so it can be`
1068
+ + ' replayed, but its content is not visible to normalized consumers.'
1069
+ );
1070
+ }
1071
+ return { type: 'text', text: '', rawItem: block } as ContentBlock;
1072
+ }
1073
+
1074
+ /** Test seam: the once-per-type warn latch is process-wide otherwise. */
1075
+ export function resetUnconvertibleBlockWarnings(): void {
1076
+ warnedUnconvertibleResponseBlocks.clear();
1077
+ }
1078
+
928
1079
  /**
929
1080
  * Convert Anthropic response content to normalized format
930
1081
  */
@@ -955,11 +1106,14 @@ export function fromAnthropicContent(blocks: Anthropic.ContentBlock[]): ContentB
955
1106
  break;
956
1107
 
957
1108
  default:
958
- // Handle redacted_thinking or unknown types
959
1109
  if ((block as any).type === 'redacted_thinking') {
960
1110
  // Preserve the encrypted `data` payload — without it the block
961
1111
  // cannot be round-tripped and prior reasoning is lost.
962
1112
  result.push({ type: 'redacted_thinking', data: (block as any).data } as any);
1113
+ } else {
1114
+ // server_tool_use, web_search_tool_result, search_result, mcp_tool_use
1115
+ // and anything Anthropic adds later used to fall out here silently.
1116
+ result.push(preserveUnconvertibleBlock(block, 'anthropic'));
963
1117
  }
964
1118
  break;
965
1119
  }
@@ -20,7 +20,7 @@ import {
20
20
  serverError,
21
21
  abortError,
22
22
  } from '../types/index.js';
23
- import { createCombinedSignal } from './utils.js';
23
+ import { createCombinedSignal, isDeadlineAbort, deadlineTimeoutError, assertTerminalEventObserved } from './utils.js';
24
24
  import {
25
25
  INTERLEAVED_THINKING_BETA,
26
26
  needsInterleavedThinkingBeta,
@@ -273,6 +273,15 @@ async function signRequest(
273
273
  export class BedrockAdapter implements ProviderAdapter {
274
274
  readonly name = 'bedrock';
275
275
 
276
+ /**
277
+ * Bedrock serves Anthropic models over the Anthropic Messages payload shape
278
+ * and this adapter reads Anthropic's own field names
279
+ * (`cache_read_input_tokens` / `cache_creation_input_tokens`), so it inherits
280
+ * the convention verified live against api.anthropic.com on 2026-08-25.
281
+ * Derived from the wire contract, not measured against Bedrock directly.
282
+ */
283
+ readonly usageCacheConvention = 'cache-excluded' as const;
284
+
276
285
  private accessKeyId: string;
277
286
  private secretAccessKey: string;
278
287
  private sessionToken?: string;
@@ -610,6 +619,7 @@ export class BedrockAdapter implements ProviderAdapter {
610
619
  let cacheCreationTokens: number | undefined;
611
620
  let cacheReadTokens: number | undefined;
612
621
  let stopReason: string = 'end_turn';
622
+ let sawTerminalEvent = false;
613
623
  let stopSequence: string | undefined;
614
624
  let fullText = '';
615
625
 
@@ -782,6 +792,7 @@ export class BedrockAdapter implements ProviderAdapter {
782
792
  }
783
793
  callbacks.onContentBlock?.(blockIdx, contentBlocks[blockIdx]);
784
794
  } else if (eventData.type === 'message_delta') {
795
+ sawTerminalEvent = true;
785
796
  if (eventData.usage) {
786
797
  outputTokens = eventData.usage.output_tokens;
787
798
  // message_delta carries cumulative cache metrics — use as
@@ -833,6 +844,11 @@ export class BedrockAdapter implements ProviderAdapter {
833
844
  );
834
845
  }
835
846
 
847
+ // The empty-response guard above only catches a stream that produced
848
+ // NOTHING; a stream truncated after any content passed it and was
849
+ // reported as a clean end_turn. message_delta is the terminal event.
850
+ assertTerminalEventObserved(sawTerminalEvent, 'Bedrock', { modelId, ...request });
851
+
836
852
  // Build response from accumulated data
837
853
  finalMessage = {
838
854
  id: 'msg_stream',
@@ -917,6 +933,14 @@ export class BedrockAdapter implements ProviderAdapter {
917
933
  }
918
934
 
919
935
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
936
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
937
+ // abortError() here is what erased the identity before Membrane's
938
+ // caller-signal > timeout > error ladder could read it.
939
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
940
+ // Already-classified failures (e.g. the stream-integrity guards) keep
941
+ // their type and retryability instead of being re-derived from a string.
942
+ if (error instanceof MembraneError) return error;
943
+
920
944
  if (error instanceof BedrockError) {
921
945
  const status = error.status;
922
946
  const message = error.message;
@@ -28,7 +28,7 @@ import {
28
28
  abortError,
29
29
  networkError,
30
30
  } from '../types/index.js';
31
- import { createCombinedSignal } from './utils.js';
31
+ import { createCombinedSignal, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
32
32
 
33
33
  // ============================================================================
34
34
  // Gemini API Types
@@ -77,11 +77,58 @@ interface GeminiResponse {
77
77
  candidatesTokenCount?: number;
78
78
  totalTokenCount?: number;
79
79
  cachedContentTokenCount?: number;
80
+ /**
81
+ * Reasoning ("thinking") tokens. DISJOINT from candidatesTokenCount and
82
+ * billed at the output rate, so generated output is candidates + thoughts.
83
+ * Live receipt 2026-08-25, gemini-3.5-flash-lite, thinkingBudget 512:
84
+ * prompt 35, candidates 2, thoughts 228, total 265 — 35+2+228 === 265.
85
+ */
86
+ thoughtsTokenCount?: number;
80
87
  };
81
88
  modelVersion?: string;
82
89
  error?: { code: number; message: string; status: string };
83
90
  }
84
91
 
92
+ /**
93
+ * Map Gemini's `usageMetadata` onto membrane's usage shape.
94
+ *
95
+ * `thoughtsTokenCount` is disjoint from `candidatesTokenCount` and billed at
96
+ * the output rate, so generated output is the SUM of the two; reading only
97
+ * candidates reported a thinking turn at a fraction of its real size. The
98
+ * reconciliation check makes the next such omission loud rather than silent:
99
+ * Google's own total is the independent witness, and a mismatch means a
100
+ * usageMetadata field membrane does not read is carrying tokens.
101
+ */
102
+ function geminiUsageToProviderUsage(
103
+ usageMetadata: GeminiResponse['usageMetadata']
104
+ ): ProviderResponse['usage'] {
105
+ const promptTokens = usageMetadata?.promptTokenCount ?? 0;
106
+ const candidatesTokens = usageMetadata?.candidatesTokenCount ?? 0;
107
+ const thoughtsTokens = usageMetadata?.thoughtsTokenCount;
108
+ const totalTokens = usageMetadata?.totalTokenCount;
109
+
110
+ if (usageMetadata && totalTokens != null) {
111
+ const accountedTokens = promptTokens + candidatesTokens + (thoughtsTokens ?? 0);
112
+ if (accountedTokens !== totalTokens) {
113
+ console.warn(
114
+ `[membrane:gemini] usageMetadata does not reconcile: promptTokenCount(${promptTokens})`
115
+ + ` + candidatesTokenCount(${candidatesTokens}) + thoughtsTokenCount(${thoughtsTokens ?? 0})`
116
+ + ` = ${accountedTokens}, but totalTokenCount = ${totalTokens}.`
117
+ + ' Some billed tokens are in a usageMetadata field membrane does not read.'
118
+ );
119
+ }
120
+ }
121
+
122
+ return {
123
+ inputTokens: promptTokens,
124
+ outputTokens: candidatesTokens + (thoughtsTokens ?? 0),
125
+ ...(thoughtsTokens != null ? { thinkingTokens: thoughtsTokens } : {}),
126
+ cacheReadTokens: usageMetadata?.cachedContentTokenCount
127
+ ? usageMetadata.cachedContentTokenCount
128
+ : undefined,
129
+ };
130
+ }
131
+
85
132
  // ============================================================================
86
133
  // Adapter Configuration
87
134
  // ============================================================================
@@ -103,6 +150,18 @@ export interface GeminiAdapterConfig {
103
150
 
104
151
  export class GeminiAdapter implements ProviderAdapter {
105
152
  readonly name = 'gemini';
153
+
154
+ /**
155
+ * NOT ESTABLISHED. Google documents `cachedContentTokenCount` but the probes
156
+ * available on 2026-08-25 could not produce a cache hit to measure against:
157
+ * three identical 10,893-token calls to gemini-3.5-flash-lite never reported
158
+ * the field (implicit caching did not trigger), and explicit `cachedContents`
159
+ * is refused on the free tier (429,
160
+ * TotalCachedContentStorageTokensPerModelFreeTier limit=0). Declared honestly
161
+ * rather than guessed — membrane passes the counts through and warns once if
162
+ * a cache read ever arrives.
163
+ */
164
+ readonly usageCacheConvention = 'unknown' as const;
106
165
  private apiKey: string;
107
166
  private baseURL: string;
108
167
  private defaultMaxTokens: number;
@@ -188,11 +247,65 @@ export class GeminiAdapter implements ProviderAdapter {
188
247
  const decoder = new TextDecoder();
189
248
  let accumulated = '';
190
249
  let finishReason = 'STOP';
250
+ let sawTerminalEvent = false;
191
251
  let toolCalls: { name: string; args: Record<string, unknown> }[] = [];
192
252
  let images: { data: string; mimeType: string }[] = [];
193
253
  let lastUsage: GeminiResponse['usageMetadata'] | undefined;
254
+ // The resolved model Google actually served, echoed on stream frames.
255
+ // Reporting the requested id instead hides alias/auto-upgrade routing.
256
+ let lastModelVersion: string | undefined;
194
257
  let buffer = '';
195
258
 
259
+ // One frame handler for both the streaming lines and the trailing
260
+ // buffer — the two used to carry byte-identical copies of this logic,
261
+ // so any fix (error frames, terminal observation) had to be made twice.
262
+ const processDataLine = (dataLine: string): void => {
263
+ let parsed: GeminiResponse;
264
+ try {
265
+ parsed = JSON.parse(dataLine) as GeminiResponse;
266
+ } catch {
267
+ return; // Ignore parse errors in stream chunks
268
+ }
269
+
270
+ throwOnStreamErrorFrame(parsed, 'Gemini', geminiRequest);
271
+
272
+ const candidate = parsed.candidates?.[0];
273
+
274
+ if (candidate?.content?.parts) {
275
+ for (const part of candidate.content.parts) {
276
+ if (part.text) {
277
+ accumulated += part.text;
278
+ callbacks.onChunk(part.text);
279
+ }
280
+ if (part.inlineData) {
281
+ images.push({
282
+ data: part.inlineData.data,
283
+ mimeType: part.inlineData.mimeType,
284
+ });
285
+ }
286
+ if (part.functionCall) {
287
+ toolCalls.push({
288
+ name: part.functionCall.name,
289
+ args: part.functionCall.args,
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ if (candidate?.finishReason) {
296
+ finishReason = candidate.finishReason;
297
+ sawTerminalEvent = true;
298
+ }
299
+
300
+ if (parsed.usageMetadata) {
301
+ lastUsage = parsed.usageMetadata;
302
+ }
303
+
304
+ if (parsed.modelVersion) {
305
+ lastModelVersion = parsed.modelVersion;
306
+ }
307
+ };
308
+
196
309
  while (true) {
197
310
  const { done, value } = await reader.read();
198
311
  if (done) break;
@@ -206,42 +319,7 @@ export class GeminiAdapter implements ProviderAdapter {
206
319
  if (!line.startsWith('data: ')) continue;
207
320
  const data = line.slice(6).trim();
208
321
  if (!data || data === '[DONE]') continue;
209
-
210
- try {
211
- const parsed = JSON.parse(data) as GeminiResponse;
212
- const candidate = parsed.candidates?.[0];
213
-
214
- if (candidate?.content?.parts) {
215
- for (const part of candidate.content.parts) {
216
- if (part.text) {
217
- accumulated += part.text;
218
- callbacks.onChunk(part.text);
219
- }
220
- if (part.inlineData) {
221
- images.push({
222
- data: part.inlineData.data,
223
- mimeType: part.inlineData.mimeType,
224
- });
225
- }
226
- if (part.functionCall) {
227
- toolCalls.push({
228
- name: part.functionCall.name,
229
- args: part.functionCall.args,
230
- });
231
- }
232
- }
233
- }
234
-
235
- if (candidate?.finishReason) {
236
- finishReason = candidate.finishReason;
237
- }
238
-
239
- if (parsed.usageMetadata) {
240
- lastUsage = parsed.usageMetadata;
241
- }
242
- } catch {
243
- // Ignore parse errors in stream chunks
244
- }
322
+ processDataLine(data);
245
323
  }
246
324
  }
247
325
 
@@ -250,56 +328,18 @@ export class GeminiAdapter implements ProviderAdapter {
250
328
  const remaining = buffer.trim();
251
329
  const dataLine = remaining.startsWith('data: ') ? remaining.slice(6).trim() : remaining;
252
330
  if (dataLine && dataLine !== '[DONE]') {
253
- try {
254
- const parsed = JSON.parse(dataLine) as GeminiResponse;
255
- const candidate = parsed.candidates?.[0];
256
-
257
- if (candidate?.content?.parts) {
258
- for (const part of candidate.content.parts) {
259
- if (part.text) {
260
- accumulated += part.text;
261
- callbacks.onChunk(part.text);
262
- }
263
- if (part.inlineData) {
264
- images.push({
265
- data: part.inlineData.data,
266
- mimeType: part.inlineData.mimeType,
267
- });
268
- }
269
- if (part.functionCall) {
270
- toolCalls.push({
271
- name: part.functionCall.name,
272
- args: part.functionCall.args,
273
- });
274
- }
275
- }
276
- }
277
-
278
- if (candidate?.finishReason) {
279
- finishReason = candidate.finishReason;
280
- }
281
-
282
- if (parsed.usageMetadata) {
283
- lastUsage = parsed.usageMetadata;
284
- }
285
- } catch {
286
- // Final buffer wasn't valid JSON — nothing to do
287
- }
331
+ processDataLine(dataLine);
288
332
  }
289
333
  }
290
334
 
335
+ assertTerminalEventObserved(sawTerminalEvent, 'Gemini', geminiRequest);
336
+
291
337
  return {
292
338
  content: this.buildContentBlocks(accumulated, toolCalls, images),
293
339
  stopReason: this.mapFinishReason(finishReason),
294
340
  stopSequence: undefined,
295
- usage: {
296
- inputTokens: lastUsage?.promptTokenCount ?? 0,
297
- outputTokens: lastUsage?.candidatesTokenCount ?? 0,
298
- cacheReadTokens: lastUsage?.cachedContentTokenCount
299
- ? lastUsage.cachedContentTokenCount
300
- : undefined,
301
- },
302
- model: request.model,
341
+ usage: geminiUsageToProviderUsage(lastUsage),
342
+ model: lastModelVersion ?? request.model,
303
343
  rawRequest: geminiRequest,
304
344
  raw: { finishReason, usage: lastUsage },
305
345
  };
@@ -563,13 +603,7 @@ export class GeminiAdapter implements ProviderAdapter {
563
603
  content: this.buildContentBlocks(text, toolCalls, images),
564
604
  stopReason: this.mapFinishReason(candidate?.finishReason),
565
605
  stopSequence: undefined,
566
- usage: {
567
- inputTokens: response.usageMetadata?.promptTokenCount ?? 0,
568
- outputTokens: response.usageMetadata?.candidatesTokenCount ?? 0,
569
- cacheReadTokens: response.usageMetadata?.cachedContentTokenCount
570
- ? response.usageMetadata.cachedContentTokenCount
571
- : undefined,
572
- },
606
+ usage: geminiUsageToProviderUsage(response.usageMetadata),
573
607
  model: response.modelVersion ?? requestedModel,
574
608
  rawRequest,
575
609
  raw: response,
@@ -630,6 +664,10 @@ export class GeminiAdapter implements ProviderAdapter {
630
664
  // --------------------------------------------------------------------------
631
665
 
632
666
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
667
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
668
+ // abortError() here is what erased the identity before Membrane's
669
+ // caller-signal > timeout > error ladder could read it.
670
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
633
671
  if (error instanceof MembraneError) return error;
634
672
 
635
673
  if (error instanceof Error) {