@animalabs/membrane 0.5.76 → 0.5.77

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.
package/src/membrane.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  DEFAULT_RETRY_CONFIG,
32
32
  MembraneError,
33
33
  classifyError,
34
+ isOverloadedError,
34
35
  isTextContent,
35
36
  isAbortedResponse,
36
37
  } from './types/index.js';
@@ -80,7 +81,11 @@ export class Membrane {
80
81
  ) {
81
82
  this.adapter = adapter;
82
83
  this.registry = config.registry;
83
- this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };
84
+ this.retryConfig = {
85
+ ...DEFAULT_RETRY_CONFIG,
86
+ ...config.retry,
87
+ overloaded: { ...DEFAULT_RETRY_CONFIG.overloaded, ...config.retry?.overloaded },
88
+ };
84
89
  this.config = config;
85
90
  // Use provided formatter or default to AnthropicXmlFormatter
86
91
  this.formatter = config.formatter ?? new AnthropicXmlFormatter();
@@ -146,12 +151,23 @@ export class Membrane {
146
151
  const errorInfo = classifyError(error);
147
152
  errorInfo.rawRequest = rawRequest;
148
153
 
149
- // Rate limits (429) always retry up to 5 attempts regardless of config.
150
- // Other retryable errors only retry when maxRetries > 0.
154
+ // Rate limits (429) always retry up to 5 attempts regardless of
155
+ // config, and overloaded (529) always retries on its own longer
156
+ // schedule — both are transient by definition, and the default
157
+ // maxRetries of 0 would otherwise turn a capacity blip into a dead
158
+ // turn. Other retryable errors only retry when maxRetries > 0.
159
+ // overloaded.maxRetries: 0 disables the dedicated policy entirely;
160
+ // the 529 then follows the base config like any retryable server
161
+ // error (exactly the pre-policy behavior), rather than being
162
+ // silently re-promoted to the long schedule by a positive base limit.
151
163
  const isRateLimit = errorInfo.type === 'rate_limit';
164
+ const isOverloaded =
165
+ isOverloadedError(errorInfo) && this.retryConfig.overloaded.maxRetries > 0;
152
166
  const effectiveMax = isRateLimit
153
167
  ? Math.max(this.retryConfig.maxRetries, 5)
154
- : this.retryConfig.maxRetries;
168
+ : isOverloaded
169
+ ? Math.max(this.retryConfig.maxRetries, this.retryConfig.overloaded.maxRetries)
170
+ : this.retryConfig.maxRetries;
155
171
 
156
172
  if (errorInfo.retryable && attempts < effectiveMax) {
157
173
  // Check hook for retry decision
@@ -163,7 +179,7 @@ export class Membrane {
163
179
  }
164
180
 
165
181
  // Wait before retry (abort-aware)
166
- const delay = this.calculateRetryDelay(attempts);
182
+ const delay = this.calculateRetryDelay(attempts, isOverloaded);
167
183
  await this.sleep(delay, options.signal);
168
184
  continue;
169
185
  }
@@ -217,11 +233,74 @@ export class Membrane {
217
233
 
218
234
  // Determine tool mode
219
235
  const toolMode = this.resolveToolMode(request);
236
+ const useNative = toolMode === 'native' && !!request.tools && request.tools.length > 0;
237
+
238
+ // Overloaded (529) pre-emission retry. The streaming paths have no retry
239
+ // loop of their own, so a capacity error used to kill the turn outright —
240
+ // and 529s most often arrive INSTEAD of a stream, before anything reaches
241
+ // the caller, where retrying is transparent. Once any callback has
242
+ // delivered output (tokens, blocks, usage), retrying would replay content
243
+ // the caller already consumed, so mid-stream errors still throw.
244
+ let attempts = 0;
245
+ const retryDelaysMs: number[] = [];
246
+ while (true) {
247
+ attempts++;
248
+ let emitted = false;
249
+ const mark = <A extends unknown[], R>(fn?: (...args: A) => R) =>
250
+ fn && ((...args: A): R => { emitted = true; return fn(...args); });
251
+ const tracked: StreamOptions = {
252
+ ...options,
253
+ onChunk: mark(options.onChunk),
254
+ onContentBlockUpdate: mark(options.onContentBlockUpdate),
255
+ onToolCalls: mark(options.onToolCalls),
256
+ onPreToolContent: mark(options.onPreToolContent),
257
+ onUsage: mark(options.onUsage),
258
+ onBlock: mark(options.onBlock),
259
+ onResponse: mark(options.onResponse),
260
+ // onRequest fires before the send — it is not an emission.
261
+ };
220
262
 
221
- if (toolMode === 'native' && request.tools && request.tools.length > 0) {
222
- return this.streamWithNativeTools(request, options);
223
- } else {
224
- return this.streamWithXmlTools(request, options);
263
+ try {
264
+ const result = useNative
265
+ ? await this.streamWithNativeTools(request, tracked)
266
+ : await this.streamWithXmlTools(request, tracked);
267
+ // The inner paths report attempts: 1 — they can't see this wrapper.
268
+ // A call that succeeded after N overloaded retries must not look like
269
+ // a first-attempt success in durable logs, so patch the real count
270
+ // (and the waits) into the response telemetry.
271
+ if (attempts > 1 && 'details' in result) {
272
+ result.details.timing.attempts = attempts;
273
+ result.details.timing.retryDelaysMs = retryDelaysMs;
274
+ }
275
+ return result;
276
+ } catch (error) {
277
+ const errorInfo = classifyError(error);
278
+ // Same semantics as complete(): maxRetries bounds total attempts,
279
+ // the overloaded floor applies over the base config, and
280
+ // overloaded.maxRetries: 0 opts out of stream retries entirely
281
+ // (streaming had no retry before this policy existed).
282
+ const overloadedEnabled = this.retryConfig.overloaded.maxRetries > 0;
283
+ const maxOverloaded = Math.max(
284
+ this.retryConfig.maxRetries,
285
+ this.retryConfig.overloaded.maxRetries
286
+ );
287
+ if (!emitted && overloadedEnabled && isOverloadedError(errorInfo) && attempts < maxOverloaded) {
288
+ // Honor the same pre-retry hook contract as complete(): hosts use
289
+ // onError for circuit-breaking, and its 'abort' decision must work
290
+ // on the streaming path too.
291
+ if (this.config.hooks?.onError) {
292
+ const decision = await this.config.hooks.onError(errorInfo, attempts);
293
+ if (decision === 'abort') {
294
+ throw error;
295
+ }
296
+ }
297
+ const delay = this.calculateRetryDelay(attempts, true);
298
+ retryDelaysMs.push(delay);
299
+ await this.sleep(delay, options.signal);
300
+ continue;
301
+ }
302
+ throw error;
303
+ }
225
304
  }
226
305
  }
227
306
 
@@ -330,6 +409,52 @@ export class Membrane {
330
409
  // from blocks the model itself opened during generation
331
410
  const prefillDepths = parser.getDepths();
332
411
 
412
+ // Resumption spin guards (issue #39). Observed live on Ash 2026-07-26:
413
+ // each automatic resumption re-sent ~172k input tokens, streamed ~6
414
+ // output tokens, and stopped on the same (dropped) stop sequence — 43
415
+ // rounds, ~7M input tokens, zero progress, found only because a human
416
+ // noticed. Two guards, both scoped to AUTOMATIC false-positive
417
+ // resumptions — tool rounds are real caller-governed work (maxToolDepth
418
+ // / the yielding API's uncapped contract) and are never counted here:
419
+ // - stall guard: several CONSECUTIVE resumptions that each stream
420
+ // almost nothing and stop identically end the turn ('no_progress').
421
+ // One short repeated round is low progress, not proof of none — a
422
+ // stop sequence inside legitimate tool-argument text can cause a
423
+ // couple of short resumptions on the way to completing.
424
+ // - round cap: a hard bound on resumptions per turn ('round_limit'),
425
+ // the backstop for a spin that keeps technically progressing.
426
+ const MIN_ROUND_PROGRESS_CHARS = 16;
427
+ const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
428
+ const RESUMPTION_WARN_ROUNDS = 5;
429
+ const maxResumptionRounds = options.maxResumptionRounds ?? 24;
430
+ let resumptionRounds = 0;
431
+ let consecutiveStalledResumptions = 0;
432
+ let enteredViaResumption = false;
433
+ let prevRoundStopSequence: string | undefined;
434
+ const warnLog = this.config.logger ?? console;
435
+
436
+ /** Count an automatic resumption; emits the visibility warning at the
437
+ * threshold and returns false when the cap says the turn should end. */
438
+ const registerResumptionRound = (): boolean => {
439
+ resumptionRounds++;
440
+ if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
441
+ warnLog.warn(
442
+ `[membrane] automatic resumption at round ${resumptionRounds} ` +
443
+ `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
444
+ `a spin shows up here before it shows up on the bill`
445
+ );
446
+ }
447
+ if (resumptionRounds > maxResumptionRounds) {
448
+ warnLog.warn(
449
+ `[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
450
+ `ending turn with stopReason 'round_limit'. ` +
451
+ `${totalUsage.inputTokens} input tokens spent this turn.`
452
+ );
453
+ return false;
454
+ }
455
+ return true;
456
+ };
457
+
333
458
  try {
334
459
  // Tool execution loop
335
460
  while (toolDepth <= maxToolDepth) {
@@ -338,7 +463,10 @@ export class Membrane {
338
463
  let detectedStopSequence: string | null = null;
339
464
  let truncatedAccumulated: string | null = null;
340
465
 
341
- // Track where to start checking for stop sequences (skip already-processed content)
466
+ // Track where to start checking for stop sequences (skip already-processed content).
467
+ // Also the round's progress baseline: XML we pushed ourselves at the
468
+ // end of the previous round (tool results, closing tags) sits below
469
+ // this index and doesn't count as model progress.
342
470
  const checkFromIndex = parser.getAccumulated().length;
343
471
 
344
472
  // Stream from provider
@@ -465,6 +593,38 @@ export class Membrane {
465
593
  // Get accumulated text from parser
466
594
  const accumulated = parser.getAccumulated();
467
595
 
596
+ // Stall accounting (issue #39): only rounds ENTERED via automatic
597
+ // resumption can stall — tool rounds are caller-governed work and a
598
+ // real tool call is longer than the threshold anyway. A stall is a
599
+ // resumption that streamed almost nothing and stopped identically to
600
+ // the previous round; the turn ends only after several IN A ROW
601
+ // (one short repeated round is low progress, not proof of none —
602
+ // a stop sequence inside legitimate tool-argument text can cause a
603
+ // couple of short resumptions on the way to completing).
604
+ const streamedThisRound = accumulated.length - checkFromIndex;
605
+ if (
606
+ enteredViaResumption &&
607
+ lastStopReason === 'stop_sequence' &&
608
+ streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
609
+ lastStopSequence === prevRoundStopSequence
610
+ ) {
611
+ consecutiveStalledResumptions++;
612
+ if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
613
+ warnLog.warn(
614
+ `[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
615
+ `made no progress (${streamedThisRound} chars this round, stop ` +
616
+ `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
617
+ `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
618
+ );
619
+ lastStopReason = 'no_progress';
620
+ break;
621
+ }
622
+ } else {
623
+ consecutiveStalledResumptions = 0;
624
+ }
625
+ prevRoundStopSequence = lastStopSequence;
626
+ enteredViaResumption = false;
627
+
468
628
  // Check for tool calls (if handler provided)
469
629
  if (onToolCalls && streamResult.stopSequence === '</function_calls>') {
470
630
  // Append the closing tag (we truncated before it, or API stopped before it)
@@ -655,7 +815,9 @@ export class Membrane {
655
815
  );
656
816
  }
657
817
 
658
- // Reset parser state for new streaming iteration
818
+ // Reset parser state for new streaming iteration. Tool rounds
819
+ // are the caller's work — they count against maxToolDepth only,
820
+ // never against the resumption guards (issue #39 review).
659
821
  parser.resetForNewIteration();
660
822
  toolDepth++;
661
823
  continue;
@@ -692,6 +854,11 @@ export class Membrane {
692
854
  if (toolDepth > maxToolDepth) {
693
855
  break;
694
856
  }
857
+ if (!registerResumptionRound()) {
858
+ lastStopReason = 'round_limit';
859
+ break;
860
+ }
861
+ enteredViaResumption = true;
695
862
  prefillResult.assistantPrefill = parser.getAccumulated();
696
863
  providerRequest = this.buildContinuationRequest(
697
864
  request,
@@ -1893,10 +2060,15 @@ export class Membrane {
1893
2060
  return pricing ? calculateCost(usage, pricing) : undefined;
1894
2061
  }
1895
2062
 
1896
- private calculateRetryDelay(attempt: number): number {
1897
- const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = this.retryConfig;
1898
- const delay = retryDelayMs * Math.pow(backoffMultiplier, attempt - 1);
1899
- return Math.min(delay, maxRetryDelayMs);
2063
+ private calculateRetryDelay(attempt: number, overloaded = false): number {
2064
+ const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = overloaded
2065
+ ? this.retryConfig.overloaded
2066
+ : this.retryConfig;
2067
+ const delay = Math.min(retryDelayMs * Math.pow(backoffMultiplier, attempt - 1), maxRetryDelayMs);
2068
+ // Equal jitter on the overloaded schedule only: a capacity storm is
2069
+ // exactly the case where a fleet retrying in sync re-creates the
2070
+ // stampede it's backing off from. [delay/2, delay) keeps the wait long.
2071
+ return overloaded ? Math.floor(delay / 2 + Math.random() * (delay / 2)) : delay;
1900
2072
  }
1901
2073
 
1902
2074
  private attachRawRequest(error: unknown, rawRequest: unknown): Error {
@@ -2033,6 +2205,29 @@ export class Membrane {
2033
2205
  ? Infinity
2034
2206
  : maxToolDepthOpt;
2035
2207
 
2208
+ // Resumption spin guards (issue #39). This is the path the Ash spin ran
2209
+ // on: tool depth here is unlimited BY DESIGN (the caller budgets its own
2210
+ // tool work — that contract stands untouched), and the false-positive
2211
+ // resumption path counted against that same unlimited bound — 43 rounds
2212
+ // × ~172k input tokens of zero progress. Only AUTOMATIC resumptions are
2213
+ // guarded: a stall guard (consecutive no-progress resumptions →
2214
+ // 'no_progress') and a hard resumption cap ('round_limit'). Tool rounds
2215
+ // are never counted. See streamWithXmlTools for the rationale details.
2216
+ const MIN_ROUND_PROGRESS_CHARS = 16;
2217
+ const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
2218
+ const RESUMPTION_WARN_ROUNDS = 5;
2219
+ const maxResumptionRounds =
2220
+ options.maxResumptionRounds === undefined
2221
+ ? 24
2222
+ : options.maxResumptionRounds === -1
2223
+ ? Infinity
2224
+ : options.maxResumptionRounds;
2225
+ let resumptionRounds = 0;
2226
+ let consecutiveStalledResumptions = 0;
2227
+ let enteredViaResumption = false;
2228
+ let prevRoundStopSequence: string | undefined;
2229
+ const warnLog = this.config.logger ?? console;
2230
+
2036
2231
  // Initialize parser from formatter for format-specific tracking
2037
2232
  const formatter = this.formatter;
2038
2233
  const parser = formatter.createStreamParser();
@@ -2078,6 +2273,28 @@ export class Membrane {
2078
2273
  // from blocks the model itself opened during generation
2079
2274
  const prefillDepths = parser.getDepths();
2080
2275
 
2276
+ /** Count an automatic resumption; emits the visibility warning at the
2277
+ * threshold and returns false when the cap says the turn should end. */
2278
+ const registerResumptionRound = (): boolean => {
2279
+ resumptionRounds++;
2280
+ if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
2281
+ warnLog.warn(
2282
+ `[membrane] automatic resumption at round ${resumptionRounds} ` +
2283
+ `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
2284
+ `a spin shows up here before it shows up on the bill`
2285
+ );
2286
+ }
2287
+ if (resumptionRounds > maxResumptionRounds) {
2288
+ warnLog.warn(
2289
+ `[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
2290
+ `ending turn with stopReason 'round_limit'. ` +
2291
+ `${totalUsage.inputTokens} input tokens spent this turn.`
2292
+ );
2293
+ return false;
2294
+ }
2295
+ return true;
2296
+ };
2297
+
2081
2298
  try {
2082
2299
  // Tool execution loop
2083
2300
  while (toolDepth <= maxToolDepth) {
@@ -2206,6 +2423,34 @@ export class Membrane {
2206
2423
  }
2207
2424
  }
2208
2425
 
2426
+ // Stall accounting (issue #39): only rounds ENTERED via automatic
2427
+ // resumption can stall; the turn ends only after several consecutive
2428
+ // stalls. Tool rounds are never counted. Mirrors streamWithXmlTools —
2429
+ // see the detailed rationale there.
2430
+ const streamedThisRound = parser.getAccumulated().length - checkFromIndex;
2431
+ if (
2432
+ enteredViaResumption &&
2433
+ lastStopReason === 'stop_sequence' &&
2434
+ streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
2435
+ lastStopSequence === prevRoundStopSequence
2436
+ ) {
2437
+ consecutiveStalledResumptions++;
2438
+ if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
2439
+ warnLog.warn(
2440
+ `[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
2441
+ `made no progress (${streamedThisRound} chars this round, stop ` +
2442
+ `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
2443
+ `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
2444
+ );
2445
+ lastStopReason = 'no_progress';
2446
+ break;
2447
+ }
2448
+ } else {
2449
+ consecutiveStalledResumptions = 0;
2450
+ }
2451
+ prevRoundStopSequence = lastStopSequence;
2452
+ enteredViaResumption = false;
2453
+
2209
2454
  // Check for tool calls
2210
2455
  if (streamResult.stopSequence === '</function_calls>') {
2211
2456
  const closeTag = '</function_calls>';
@@ -2422,6 +2667,9 @@ export class Membrane {
2422
2667
  );
2423
2668
  }
2424
2669
 
2670
+ // Tool rounds are the caller's work — they count against
2671
+ // maxToolDepth only, never against the resumption guards
2672
+ // (issue #39 review: the uncapped tool-loop contract stands).
2425
2673
  parser.resetForNewIteration();
2426
2674
  toolDepth++;
2427
2675
  continue;
@@ -2453,6 +2701,11 @@ export class Membrane {
2453
2701
  if (toolDepth > maxToolDepth) {
2454
2702
  break;
2455
2703
  }
2704
+ if (!registerResumptionRound()) {
2705
+ lastStopReason = 'round_limit';
2706
+ break;
2707
+ }
2708
+ enteredViaResumption = true;
2456
2709
  prefillResult.assistantPrefill = parser.getAccumulated();
2457
2710
  providerRequest = this.buildContinuationRequest(
2458
2711
  request,
@@ -49,6 +49,16 @@ export interface BedrockAdapterConfig {
49
49
 
50
50
  /** Anthropic API version header (defaults to 2023-06-01) */
51
51
  anthropicVersion?: string;
52
+
53
+ /**
54
+ * Endpoint override (no trailing slash), e.g. an inference gateway leg
55
+ * like `https://gate.animalabs.ai/bedrock/apse1`. Defaults to
56
+ * `https://bedrock-runtime.{region}.amazonaws.com`. When pointing at a
57
+ * gateway, set accessKeyId to the gate token — the gateway reads it from
58
+ * the SigV4 Credential field, discards the client signature, and re-signs
59
+ * with real AWS creds that never leave the gateway box.
60
+ */
61
+ baseURL?: string;
52
62
  }
53
63
 
54
64
  // ============================================================================
@@ -120,9 +130,25 @@ interface BedrockStreamEvent {
120
130
  usage?: {
121
131
  input_tokens: number;
122
132
  output_tokens: number;
133
+ cache_creation_input_tokens?: number | null;
134
+ cache_read_input_tokens?: number | null;
123
135
  };
124
136
  }
125
137
 
138
+ /**
139
+ * Bedrock accepts `cache_control: { type: 'ephemeral' }` but rejects the
140
+ * direct-API `ttl` extension ("cache_control.ttl: Extra inputs are not
141
+ * permitted"). Drop the ttl, keep the marker — the cache still works, at
142
+ * Bedrock's fixed default TTL.
143
+ */
144
+ function stripCacheTtl<T extends Record<string, any>>(block: T): T {
145
+ if (block?.cache_control && typeof block.cache_control === 'object' && 'ttl' in block.cache_control) {
146
+ const { ttl, ...cacheControl } = block.cache_control;
147
+ return { ...block, cache_control: cacheControl };
148
+ }
149
+ return block;
150
+ }
151
+
126
152
  // ============================================================================
127
153
  // AWS Signature V4 Implementation
128
154
  // ============================================================================
@@ -252,6 +278,7 @@ export class BedrockAdapter implements ProviderAdapter {
252
278
  private region: string;
253
279
  private defaultMaxTokens: number;
254
280
  private anthropicVersion: string;
281
+ private baseURL?: string;
255
282
 
256
283
  constructor(config: BedrockAdapterConfig = {}) {
257
284
  this.accessKeyId = config.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID ?? '';
@@ -260,6 +287,7 @@ export class BedrockAdapter implements ProviderAdapter {
260
287
  this.region = config.region ?? process.env.AWS_REGION ?? 'us-west-2';
261
288
  this.defaultMaxTokens = config.defaultMaxTokens ?? 4096;
262
289
  this.anthropicVersion = config.anthropicVersion ?? 'bedrock-2023-05-31';
290
+ this.baseURL = config.baseURL?.replace(/\/$/, '');
263
291
 
264
292
  if (!this.accessKeyId || !this.secretAccessKey) {
265
293
  throw new Error('AWS credentials required: accessKeyId and secretAccessKey');
@@ -271,6 +299,18 @@ export class BedrockAdapter implements ProviderAdapter {
271
299
  return modelId.includes('claude') || modelId.startsWith('anthropic.');
272
300
  }
273
301
 
302
+ /**
303
+ * Cross-region inference-profile prefix for this adapter's region.
304
+ * Claude 4-era models on Bedrock reject on-demand invocation of the
305
+ * direct id ("Invocation ... with on-demand throughput isn't supported")
306
+ * and require the profile form — verified live 2026-07-31.
307
+ */
308
+ private inferenceProfilePrefix(): string {
309
+ if (this.region.startsWith('eu-')) return 'eu.';
310
+ if (this.region.startsWith('ap-')) return 'apac.';
311
+ return 'us.';
312
+ }
313
+
274
314
  /**
275
315
  * Convert a standard Claude model ID to Bedrock format if needed
276
316
  */
@@ -287,7 +327,13 @@ export class BedrockAdapter implements ProviderAdapter {
287
327
  return modelId;
288
328
  }
289
329
 
290
- // Map common Claude model IDs to Bedrock format
330
+ const profile = this.inferenceProfilePrefix();
331
+
332
+ // Map common Claude model IDs to Bedrock format. The 3.x entries keep
333
+ // their historical direct-id form (those models predate inference
334
+ // profiles; all are EOL on Bedrock as of 2026-07 anyway, so the exact
335
+ // shape is moot). 4-era entries use the profile form — the direct id
336
+ // no longer invokes.
291
337
  const modelMap: Record<string, string> = {
292
338
  'claude-3-5-sonnet-20241022': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
293
339
  'claude-3-5-sonnet-latest': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
@@ -296,13 +342,15 @@ export class BedrockAdapter implements ProviderAdapter {
296
342
  'claude-3-opus-20240229': 'anthropic.claude-3-opus-20240229-v1:0',
297
343
  'claude-3-sonnet-20240229': 'anthropic.claude-3-sonnet-20240229-v1:0',
298
344
  'claude-3-haiku-20240307': 'anthropic.claude-3-haiku-20240307-v1:0',
299
- 'claude-sonnet-4-20250514': 'anthropic.claude-sonnet-4-20250514-v1:0',
300
- 'claude-opus-4-20250514': 'anthropic.claude-opus-4-20250514-v1:0',
301
- // Haiku 4.5 aliases
302
- 'claude-haiku-4-5-20251001': 'anthropic.claude-3-5-haiku-20241022-v1:0',
345
+ 'claude-sonnet-4-20250514': `${profile}anthropic.claude-sonnet-4-20250514-v1:0`,
346
+ 'claude-opus-4-20250514': `${profile}anthropic.claude-opus-4-20250514-v1:0`,
347
+ // Haiku 4.5 previously aliased to 3.5 Haiku (a stand-in from before
348
+ // Haiku 4.5 reached Bedrock). 3.5 Haiku is EOL on Bedrock now, so the
349
+ // alias routed every plain-id caller to a guaranteed error.
350
+ 'claude-haiku-4-5-20251001': `${profile}anthropic.claude-haiku-4-5-20251001-v1:0`,
303
351
  };
304
352
 
305
- return modelMap[modelId] ?? `anthropic.${modelId}-v1:0`;
353
+ return modelMap[modelId] ?? `${profile}anthropic.${modelId}-v1:0`;
306
354
  }
307
355
 
308
356
  async complete(
@@ -347,7 +395,14 @@ export class BedrockAdapter implements ProviderAdapter {
347
395
 
348
396
  private buildRequest(request: ProviderRequest, bedrockModelId?: string): BedrockMessageRequest {
349
397
  // Strip provider-specific fields (e.g., sourceUrl for Gemini) from image blocks
350
- // before sending to Bedrock/Anthropic, which rejects extra inputs
398
+ // before sending to Bedrock/Anthropic, which rejects extra inputs.
399
+ //
400
+ // Same treatment for cache_control.ttl: Bedrock's prompt cache runs at the
401
+ // fixed default (5m) TTL — the ttl field is a direct-API extension and
402
+ // Bedrock rejects it as an extra input. The marker itself is fine and
403
+ // caching works without the field, so strip just the ttl and keep the
404
+ // breakpoint. Transport quirks belong to the transport, not to every
405
+ // caller that sets cacheTtl. (Connectome issue #35.)
351
406
  const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
352
407
  if (!Array.isArray(msg.content)) return msg;
353
408
  return {
@@ -355,9 +410,9 @@ export class BedrockAdapter implements ProviderAdapter {
355
410
  content: msg.content.map((block: any) => {
356
411
  if (block.type === 'image' && block.sourceUrl !== undefined) {
357
412
  const { sourceUrl, ...rest } = block;
358
- return rest;
413
+ return stripCacheTtl(rest);
359
414
  }
360
- return block;
415
+ return stripCacheTtl(block);
361
416
  }),
362
417
  };
363
418
  });
@@ -377,6 +432,10 @@ export class BedrockAdapter implements ProviderAdapter {
377
432
  if (needsFlatten && Array.isArray(request.system)) {
378
433
  const blocks = request.system as Array<{ type: string; text: string }>;
379
434
  params.system = blocks.map(b => b.text).join('\n\n');
435
+ } else if (Array.isArray(request.system)) {
436
+ params.system = (request.system as Array<Record<string, any>>).map(
437
+ b => stripCacheTtl(b)
438
+ ) as BedrockMessageRequest['system'];
380
439
  } else {
381
440
  params.system = request.system as BedrockMessageRequest['system'];
382
441
  }
@@ -400,7 +459,7 @@ export class BedrockAdapter implements ProviderAdapter {
400
459
  }
401
460
 
402
461
  if (request.tools && request.tools.length > 0) {
403
- params.tools = request.tools;
462
+ params.tools = (request.tools as Array<Record<string, any>>).map(t => stripCacheTtl(t));
404
463
  }
405
464
 
406
465
  // Handle extended thinking
@@ -434,7 +493,7 @@ export class BedrockAdapter implements ProviderAdapter {
434
493
  signal?: AbortSignal
435
494
  ): Promise<BedrockMessageResponse> {
436
495
  const url = new URL(
437
- `https://bedrock-runtime.${this.region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke`
496
+ `${this.baseURL ?? `https://bedrock-runtime.${this.region}.amazonaws.com`}/model/${encodeURIComponent(modelId)}/invoke`
438
497
  );
439
498
 
440
499
  const body = JSON.stringify(request);
@@ -477,7 +536,7 @@ export class BedrockAdapter implements ProviderAdapter {
477
536
  signal?: AbortSignal
478
537
  ): Promise<ProviderResponse> {
479
538
  const url = new URL(
480
- `https://bedrock-runtime.${this.region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`
539
+ `${this.baseURL ?? `https://bedrock-runtime.${this.region}.amazonaws.com`}/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`
481
540
  );
482
541
 
483
542
  const body = JSON.stringify(request);
@@ -516,6 +575,8 @@ export class BedrockAdapter implements ProviderAdapter {
516
575
  let finalMessage: BedrockMessageResponse | undefined;
517
576
  let inputTokens = 0;
518
577
  let outputTokens = 0;
578
+ let cacheCreationTokens: number | undefined;
579
+ let cacheReadTokens: number | undefined;
519
580
  let stopReason: string = 'end_turn';
520
581
  let stopSequence: string | undefined;
521
582
  let fullText = '';
@@ -630,7 +691,19 @@ export class BedrockAdapter implements ProviderAdapter {
630
691
  }
631
692
 
632
693
  if (eventData.type === 'message_start' && eventData.message) {
633
- inputTokens = eventData.message.usage?.input_tokens ?? 0;
694
+ // Cache metrics ride the same usage objects as on the direct
695
+ // API. Dropping them (pre-2026-07-31) made caching look
696
+ // permanently inert on Bedrock streams: complete() surfaced
697
+ // them, stream() zeroed them, and every ledger/pricing
698
+ // consumer downstream saw zeros. (Connectome issue #35.)
699
+ const startUsage = eventData.message.usage;
700
+ inputTokens = startUsage?.input_tokens ?? 0;
701
+ if (startUsage?.cache_creation_input_tokens != null) {
702
+ cacheCreationTokens = startUsage.cache_creation_input_tokens;
703
+ }
704
+ if (startUsage?.cache_read_input_tokens != null) {
705
+ cacheReadTokens = startUsage.cache_read_input_tokens;
706
+ }
634
707
  } else if (eventData.type === 'content_block_start') {
635
708
  currentBlockIndex = eventData.index ?? 0;
636
709
  contentBlocks[currentBlockIndex] = eventData.content_block as { type: string };
@@ -679,6 +752,15 @@ export class BedrockAdapter implements ProviderAdapter {
679
752
  } else if (eventData.type === 'message_delta') {
680
753
  if (eventData.usage) {
681
754
  outputTokens = eventData.usage.output_tokens;
755
+ // message_delta carries cumulative cache metrics — use as
756
+ // authoritative when present (same contract as the
757
+ // Anthropic adapter).
758
+ if (eventData.usage.cache_creation_input_tokens != null) {
759
+ cacheCreationTokens = eventData.usage.cache_creation_input_tokens;
760
+ }
761
+ if (eventData.usage.cache_read_input_tokens != null) {
762
+ cacheReadTokens = eventData.usage.cache_read_input_tokens;
763
+ }
682
764
  }
683
765
  if (eventData.delta?.stop_reason) {
684
766
  stopReason = eventData.delta.stop_reason;
@@ -751,6 +833,8 @@ export class BedrockAdapter implements ProviderAdapter {
751
833
  usage: {
752
834
  input_tokens: inputTokens,
753
835
  output_tokens: outputTokens,
836
+ ...(cacheCreationTokens != null ? { cache_creation_input_tokens: cacheCreationTokens } : {}),
837
+ ...(cacheReadTokens != null ? { cache_read_input_tokens: cacheReadTokens } : {}),
754
838
  },
755
839
  };
756
840