@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/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +242 -17
- package/dist/membrane.js.map +1 -1
- package/dist/providers/bedrock.d.ts +17 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +83 -13
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/types/config.d.ts +31 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +8 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/errors.d.ts +12 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +19 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/response.d.ts +1 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +10 -0
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/types/yielding-stream.d.ts +12 -0
- package/dist/types/yielding-stream.d.ts.map +1 -1
- package/dist/types/yielding-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/membrane.ts +268 -15
- package/src/providers/bedrock.ts +97 -13
- package/src/types/config.ts +48 -4
- package/src/types/errors.ts +18 -0
- package/src/types/index.ts +1 -0
- package/src/types/response.ts +5 -1
- package/src/types/streaming.ts +11 -0
- package/src/types/yielding-stream.ts +13 -0
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 = {
|
|
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
|
|
150
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 } =
|
|
1898
|
-
|
|
1899
|
-
|
|
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,
|
package/src/providers/bedrock.ts
CHANGED
|
@@ -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
|
-
|
|
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':
|
|
300
|
-
'claude-opus-4-20250514':
|
|
301
|
-
// Haiku 4.5
|
|
302
|
-
|
|
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] ??
|
|
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
|
-
|
|
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
|
|