@animalabs/membrane 0.5.76 → 0.5.78
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 +301 -19
- 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 +86 -15
- 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 +24 -0
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/types/yielding-stream.d.ts +57 -1
- 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 +350 -17
- package/src/providers/bedrock.ts +100 -15
- 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 +26 -0
- package/src/types/yielding-stream.ts +60 -0
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
|
*/
|
|
@@ -282,12 +322,19 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
282
322
|
|
|
283
323
|
// If already in Bedrock format, use as-is
|
|
284
324
|
// Supports both direct model IDs (anthropic.claude-...) and
|
|
285
|
-
// cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-...,
|
|
286
|
-
|
|
325
|
+
// cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-...,
|
|
326
|
+
// apac.anthropic.claude-..., global.anthropic.claude-... — `global` is 6 chars, hence {2,6}).
|
|
327
|
+
if (modelId.startsWith('anthropic.') || /^[a-z]{2,6}\.anthropic\./.test(modelId)) {
|
|
287
328
|
return modelId;
|
|
288
329
|
}
|
|
289
330
|
|
|
290
|
-
|
|
331
|
+
const profile = this.inferenceProfilePrefix();
|
|
332
|
+
|
|
333
|
+
// Map common Claude model IDs to Bedrock format. The 3.x entries keep
|
|
334
|
+
// their historical direct-id form (those models predate inference
|
|
335
|
+
// profiles; all are EOL on Bedrock as of 2026-07 anyway, so the exact
|
|
336
|
+
// shape is moot). 4-era entries use the profile form — the direct id
|
|
337
|
+
// no longer invokes.
|
|
291
338
|
const modelMap: Record<string, string> = {
|
|
292
339
|
'claude-3-5-sonnet-20241022': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
293
340
|
'claude-3-5-sonnet-latest': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
@@ -296,13 +343,15 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
296
343
|
'claude-3-opus-20240229': 'anthropic.claude-3-opus-20240229-v1:0',
|
|
297
344
|
'claude-3-sonnet-20240229': 'anthropic.claude-3-sonnet-20240229-v1:0',
|
|
298
345
|
'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
|
-
|
|
346
|
+
'claude-sonnet-4-20250514': `${profile}anthropic.claude-sonnet-4-20250514-v1:0`,
|
|
347
|
+
'claude-opus-4-20250514': `${profile}anthropic.claude-opus-4-20250514-v1:0`,
|
|
348
|
+
// Haiku 4.5 previously aliased to 3.5 Haiku (a stand-in from before
|
|
349
|
+
// Haiku 4.5 reached Bedrock). 3.5 Haiku is EOL on Bedrock now, so the
|
|
350
|
+
// alias routed every plain-id caller to a guaranteed error.
|
|
351
|
+
'claude-haiku-4-5-20251001': `${profile}anthropic.claude-haiku-4-5-20251001-v1:0`,
|
|
303
352
|
};
|
|
304
353
|
|
|
305
|
-
return modelMap[modelId] ??
|
|
354
|
+
return modelMap[modelId] ?? `${profile}anthropic.${modelId}-v1:0`;
|
|
306
355
|
}
|
|
307
356
|
|
|
308
357
|
async complete(
|
|
@@ -347,7 +396,14 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
347
396
|
|
|
348
397
|
private buildRequest(request: ProviderRequest, bedrockModelId?: string): BedrockMessageRequest {
|
|
349
398
|
// Strip provider-specific fields (e.g., sourceUrl for Gemini) from image blocks
|
|
350
|
-
// before sending to Bedrock/Anthropic, which rejects extra inputs
|
|
399
|
+
// before sending to Bedrock/Anthropic, which rejects extra inputs.
|
|
400
|
+
//
|
|
401
|
+
// Same treatment for cache_control.ttl: Bedrock's prompt cache runs at the
|
|
402
|
+
// fixed default (5m) TTL — the ttl field is a direct-API extension and
|
|
403
|
+
// Bedrock rejects it as an extra input. The marker itself is fine and
|
|
404
|
+
// caching works without the field, so strip just the ttl and keep the
|
|
405
|
+
// breakpoint. Transport quirks belong to the transport, not to every
|
|
406
|
+
// caller that sets cacheTtl. (Connectome issue #35.)
|
|
351
407
|
const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
|
|
352
408
|
if (!Array.isArray(msg.content)) return msg;
|
|
353
409
|
return {
|
|
@@ -355,9 +411,9 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
355
411
|
content: msg.content.map((block: any) => {
|
|
356
412
|
if (block.type === 'image' && block.sourceUrl !== undefined) {
|
|
357
413
|
const { sourceUrl, ...rest } = block;
|
|
358
|
-
return rest;
|
|
414
|
+
return stripCacheTtl(rest);
|
|
359
415
|
}
|
|
360
|
-
return block;
|
|
416
|
+
return stripCacheTtl(block);
|
|
361
417
|
}),
|
|
362
418
|
};
|
|
363
419
|
});
|
|
@@ -377,6 +433,10 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
377
433
|
if (needsFlatten && Array.isArray(request.system)) {
|
|
378
434
|
const blocks = request.system as Array<{ type: string; text: string }>;
|
|
379
435
|
params.system = blocks.map(b => b.text).join('\n\n');
|
|
436
|
+
} else if (Array.isArray(request.system)) {
|
|
437
|
+
params.system = (request.system as Array<Record<string, any>>).map(
|
|
438
|
+
b => stripCacheTtl(b)
|
|
439
|
+
) as BedrockMessageRequest['system'];
|
|
380
440
|
} else {
|
|
381
441
|
params.system = request.system as BedrockMessageRequest['system'];
|
|
382
442
|
}
|
|
@@ -400,7 +460,7 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
400
460
|
}
|
|
401
461
|
|
|
402
462
|
if (request.tools && request.tools.length > 0) {
|
|
403
|
-
params.tools = request.tools;
|
|
463
|
+
params.tools = (request.tools as Array<Record<string, any>>).map(t => stripCacheTtl(t));
|
|
404
464
|
}
|
|
405
465
|
|
|
406
466
|
// Handle extended thinking
|
|
@@ -434,7 +494,7 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
434
494
|
signal?: AbortSignal
|
|
435
495
|
): Promise<BedrockMessageResponse> {
|
|
436
496
|
const url = new URL(
|
|
437
|
-
`https://bedrock-runtime.${this.region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke`
|
|
497
|
+
`${this.baseURL ?? `https://bedrock-runtime.${this.region}.amazonaws.com`}/model/${encodeURIComponent(modelId)}/invoke`
|
|
438
498
|
);
|
|
439
499
|
|
|
440
500
|
const body = JSON.stringify(request);
|
|
@@ -477,7 +537,7 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
477
537
|
signal?: AbortSignal
|
|
478
538
|
): Promise<ProviderResponse> {
|
|
479
539
|
const url = new URL(
|
|
480
|
-
`https://bedrock-runtime.${this.region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`
|
|
540
|
+
`${this.baseURL ?? `https://bedrock-runtime.${this.region}.amazonaws.com`}/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`
|
|
481
541
|
);
|
|
482
542
|
|
|
483
543
|
const body = JSON.stringify(request);
|
|
@@ -516,6 +576,8 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
516
576
|
let finalMessage: BedrockMessageResponse | undefined;
|
|
517
577
|
let inputTokens = 0;
|
|
518
578
|
let outputTokens = 0;
|
|
579
|
+
let cacheCreationTokens: number | undefined;
|
|
580
|
+
let cacheReadTokens: number | undefined;
|
|
519
581
|
let stopReason: string = 'end_turn';
|
|
520
582
|
let stopSequence: string | undefined;
|
|
521
583
|
let fullText = '';
|
|
@@ -630,7 +692,19 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
630
692
|
}
|
|
631
693
|
|
|
632
694
|
if (eventData.type === 'message_start' && eventData.message) {
|
|
633
|
-
|
|
695
|
+
// Cache metrics ride the same usage objects as on the direct
|
|
696
|
+
// API. Dropping them (pre-2026-07-31) made caching look
|
|
697
|
+
// permanently inert on Bedrock streams: complete() surfaced
|
|
698
|
+
// them, stream() zeroed them, and every ledger/pricing
|
|
699
|
+
// consumer downstream saw zeros. (Connectome issue #35.)
|
|
700
|
+
const startUsage = eventData.message.usage;
|
|
701
|
+
inputTokens = startUsage?.input_tokens ?? 0;
|
|
702
|
+
if (startUsage?.cache_creation_input_tokens != null) {
|
|
703
|
+
cacheCreationTokens = startUsage.cache_creation_input_tokens;
|
|
704
|
+
}
|
|
705
|
+
if (startUsage?.cache_read_input_tokens != null) {
|
|
706
|
+
cacheReadTokens = startUsage.cache_read_input_tokens;
|
|
707
|
+
}
|
|
634
708
|
} else if (eventData.type === 'content_block_start') {
|
|
635
709
|
currentBlockIndex = eventData.index ?? 0;
|
|
636
710
|
contentBlocks[currentBlockIndex] = eventData.content_block as { type: string };
|
|
@@ -679,6 +753,15 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
679
753
|
} else if (eventData.type === 'message_delta') {
|
|
680
754
|
if (eventData.usage) {
|
|
681
755
|
outputTokens = eventData.usage.output_tokens;
|
|
756
|
+
// message_delta carries cumulative cache metrics — use as
|
|
757
|
+
// authoritative when present (same contract as the
|
|
758
|
+
// Anthropic adapter).
|
|
759
|
+
if (eventData.usage.cache_creation_input_tokens != null) {
|
|
760
|
+
cacheCreationTokens = eventData.usage.cache_creation_input_tokens;
|
|
761
|
+
}
|
|
762
|
+
if (eventData.usage.cache_read_input_tokens != null) {
|
|
763
|
+
cacheReadTokens = eventData.usage.cache_read_input_tokens;
|
|
764
|
+
}
|
|
682
765
|
}
|
|
683
766
|
if (eventData.delta?.stop_reason) {
|
|
684
767
|
stopReason = eventData.delta.stop_reason;
|
|
@@ -751,6 +834,8 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
751
834
|
usage: {
|
|
752
835
|
input_tokens: inputTokens,
|
|
753
836
|
output_tokens: outputTokens,
|
|
837
|
+
...(cacheCreationTokens != null ? { cache_creation_input_tokens: cacheCreationTokens } : {}),
|
|
838
|
+
...(cacheReadTokens != null ? { cache_read_input_tokens: cacheReadTokens } : {}),
|
|
754
839
|
},
|
|
755
840
|
};
|
|
756
841
|
|
package/src/types/config.ts
CHANGED
|
@@ -15,17 +15,53 @@ import type { PrefillFormatter } from '../formatters/types.js';
|
|
|
15
15
|
export interface RetryConfig {
|
|
16
16
|
/** Maximum number of retry attempts (default: 3) */
|
|
17
17
|
maxRetries: number;
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
/** Initial retry delay in milliseconds (default: 1000) */
|
|
20
20
|
retryDelayMs: number;
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
/** Backoff multiplier (default: 2) */
|
|
23
23
|
backoffMultiplier: number;
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
/** Maximum retry delay (default: 30000) */
|
|
26
26
|
maxRetryDelayMs: number;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Separate, longer schedule for provider capacity errors (529
|
|
30
|
+
* overloaded_error). Capacity storms last minutes, not seconds — the
|
|
31
|
+
* standard schedule's 30s ceiling turns one into a dead turn. Overloaded
|
|
32
|
+
* retries are always attempted (mirroring the forced 429 retries), with
|
|
33
|
+
* jitter so a fleet backing off doesn't re-create the stampede in sync.
|
|
34
|
+
*
|
|
35
|
+
* maxRetries: 0 here disables this dedicated policy entirely: 529s then
|
|
36
|
+
* follow the base retry config like any other retryable server error
|
|
37
|
+
* (no forced retries, base schedule, no stream-path retry) — the exact
|
|
38
|
+
* pre-policy behavior.
|
|
39
|
+
*/
|
|
40
|
+
overloaded: OverloadedRetryConfig;
|
|
27
41
|
}
|
|
28
42
|
|
|
43
|
+
export interface OverloadedRetryConfig {
|
|
44
|
+
/** Attempt bound for overloaded errors, applied even when the base
|
|
45
|
+
* maxRetries is 0. Like the base maxRetries (and the forced 429 path),
|
|
46
|
+
* this bounds TOTAL attempts, not retries-after-the-first (default: 7) */
|
|
47
|
+
maxRetries: number;
|
|
48
|
+
|
|
49
|
+
/** Initial overloaded retry delay in milliseconds (default: 10000) */
|
|
50
|
+
retryDelayMs: number;
|
|
51
|
+
|
|
52
|
+
/** Backoff multiplier (default: 2) */
|
|
53
|
+
backoffMultiplier: number;
|
|
54
|
+
|
|
55
|
+
/** Maximum overloaded retry delay (default: 300000 — 5 minutes) */
|
|
56
|
+
maxRetryDelayMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shape accepted by MembraneConfig.retry — every field optional, including
|
|
60
|
+
* inside the nested overloaded schedule. */
|
|
61
|
+
export type RetryConfigInput = Partial<Omit<RetryConfig, 'overloaded'>> & {
|
|
62
|
+
overloaded?: Partial<OverloadedRetryConfig>;
|
|
63
|
+
};
|
|
64
|
+
|
|
29
65
|
// ============================================================================
|
|
30
66
|
// Media Processing Config
|
|
31
67
|
// ============================================================================
|
|
@@ -152,7 +188,7 @@ export interface MembraneConfig {
|
|
|
152
188
|
formatter?: PrefillFormatter;
|
|
153
189
|
|
|
154
190
|
/** Retry configuration */
|
|
155
|
-
retry?:
|
|
191
|
+
retry?: RetryConfigInput;
|
|
156
192
|
|
|
157
193
|
/** Media processing configuration */
|
|
158
194
|
media?: Partial<MediaConfig>;
|
|
@@ -176,6 +212,14 @@ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
|
176
212
|
retryDelayMs: 1000,
|
|
177
213
|
backoffMultiplier: 2,
|
|
178
214
|
maxRetryDelayMs: 30000,
|
|
215
|
+
// 7 attempts = 6 waits: 10s → 20s → 40s → 80s → 160s → 300s, ~10 minutes
|
|
216
|
+
// of patience in total — the scale capacity storms actually resolve on.
|
|
217
|
+
overloaded: {
|
|
218
|
+
maxRetries: 7,
|
|
219
|
+
retryDelayMs: 10_000,
|
|
220
|
+
backoffMultiplier: 2,
|
|
221
|
+
maxRetryDelayMs: 300_000,
|
|
222
|
+
},
|
|
179
223
|
};
|
|
180
224
|
|
|
181
225
|
export const DEFAULT_MEDIA_CONFIG: MediaConfig = {
|
package/src/types/errors.ts
CHANGED
|
@@ -239,6 +239,24 @@ export function unsupportedError(message: string, rawRequest?: unknown): Membran
|
|
|
239
239
|
// Error Classification
|
|
240
240
|
// ============================================================================
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Provider capacity exhaustion — Anthropic 529 overloaded_error, whichever
|
|
244
|
+
* path it arrived by (structured status from the provider handler, or the
|
|
245
|
+
* message-matched fallbacks in classifyError). Used only to CHOOSE the retry
|
|
246
|
+
* schedule among already-retryable errors, never to decide retryability.
|
|
247
|
+
* Matches the same deliberately narrow tokens as classifyError's fallback
|
|
248
|
+
* (status/`529`/exact `overloaded_error`) — a bare 'overloaded' in prose
|
|
249
|
+
* (e.g. "worker pool overloaded") must not put an unrelated error onto the
|
|
250
|
+
* ~10-minute schedule. The provider handlers' own bare-'overloaded' safety
|
|
251
|
+
* nets attach httpStatus 529, so those still land here via the status check.
|
|
252
|
+
*/
|
|
253
|
+
export function isOverloadedError(info: ErrorInfo): boolean {
|
|
254
|
+
if (!info.retryable) return false;
|
|
255
|
+
if (info.httpStatus === 529) return true;
|
|
256
|
+
const m = info.message.toLowerCase();
|
|
257
|
+
return m.includes('529') || m.includes('overloaded_error');
|
|
258
|
+
}
|
|
259
|
+
|
|
242
260
|
export function classifyError(error: unknown): ErrorInfo {
|
|
243
261
|
if (error instanceof MembraneError) {
|
|
244
262
|
return error.toErrorInfo();
|
package/src/types/index.ts
CHANGED
package/src/types/response.ts
CHANGED
|
@@ -15,7 +15,11 @@ export type StopReason =
|
|
|
15
15
|
| 'stop_sequence' // Hit stop sequence
|
|
16
16
|
| 'tool_use' // Stopped for tool use
|
|
17
17
|
| 'refusal' // Content refused by safety
|
|
18
|
-
| 'abort'
|
|
18
|
+
| 'abort' // Request was aborted
|
|
19
|
+
| 'no_progress' // Stall guard ended the turn (issue #39): consecutive
|
|
20
|
+
// automatic resumptions re-sent context without advancing
|
|
21
|
+
| 'round_limit'; // Resumption round cap ended the turn: the turn kept
|
|
22
|
+
// resuming (with progress) past maxResumptionRounds
|
|
19
23
|
|
|
20
24
|
// ============================================================================
|
|
21
25
|
// Usage Information
|
package/src/types/streaming.ts
CHANGED
|
@@ -231,6 +231,17 @@ export interface StreamOptions {
|
|
|
231
231
|
/** Maximum tool execution depth (default: 10) */
|
|
232
232
|
maxToolDepth?: number;
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Cap on AUTOMATIC false-positive stop-sequence resumptions per turn —
|
|
236
|
+
* membrane's own re-streams, not the caller's work. Tool rounds are
|
|
237
|
+
* deliberately not counted: they are governed by maxToolDepth and caller
|
|
238
|
+
* policy. Distinct from maxToolDepth on purpose: raising the tool budget
|
|
239
|
+
* for deep chains must not also raise how often a turn may re-send its
|
|
240
|
+
* full context on membrane's own initiative (issue #39). Exceeding it
|
|
241
|
+
* ends the turn with stopReason 'round_limit'. Default: 24.
|
|
242
|
+
*/
|
|
243
|
+
maxResumptionRounds?: number;
|
|
244
|
+
|
|
234
245
|
/** Timeout for each tool execution */
|
|
235
246
|
toolTimeoutMs?: number;
|
|
236
247
|
|
|
@@ -251,6 +262,21 @@ export interface CompleteOptions {
|
|
|
251
262
|
/** Abort signal for cancellation */
|
|
252
263
|
signal?: AbortSignal;
|
|
253
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Re-issue the request when it ends with `stop_reason: 'refusal'`, up to
|
|
267
|
+
* this many times. Default 0 (off).
|
|
268
|
+
*
|
|
269
|
+
* Safe and invisible on this path: nothing has been emitted to the caller
|
|
270
|
+
* yet, so a discarded attempt leaves no trace beyond its output tokens
|
|
271
|
+
* (the replay is cache-warm on input). The streaming equivalent needs the
|
|
272
|
+
* caller to handle `RetryingEvent` — see YieldingStreamOptions.
|
|
273
|
+
*
|
|
274
|
+
* Near the content-policy threshold a refusal is probabilistic rather than
|
|
275
|
+
* a property of the payload, so re-asking is the cheapest correct response
|
|
276
|
+
* — cheaper and far less invasive than rewriting the conversation.
|
|
277
|
+
*/
|
|
278
|
+
refusalRetries?: number;
|
|
279
|
+
|
|
254
280
|
/** Request timeout */
|
|
255
281
|
timeoutMs?: number;
|
|
256
282
|
|
|
@@ -69,6 +69,35 @@ export interface ErrorEvent {
|
|
|
69
69
|
error: Error;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Retrying event — the provider ended the attempt with
|
|
74
|
+
* `stop_reason: 'refusal'` and membrane is re-issuing it (opt-in via
|
|
75
|
+
* `refusalRetries`).
|
|
76
|
+
*
|
|
77
|
+
* **The consumer MUST discard everything this call has emitted so far**:
|
|
78
|
+
* `tokens`, `block`, and any partially built assistant content belong to an
|
|
79
|
+
* attempt that no longer exists. A fresh sequence follows. Consumers that
|
|
80
|
+
* have already shown those tokens to a human (a TUI, a chat surface) must
|
|
81
|
+
* retract or overwrite them.
|
|
82
|
+
*
|
|
83
|
+
* Why this exists: near the classifier threshold a refusal is probabilistic
|
|
84
|
+
* rather than a property of the payload — the same bytes pass and refuse
|
|
85
|
+
* minutes apart — so re-asking is the cheapest correct response. Retrying
|
|
86
|
+
* silently would corrupt any consumer that already rendered the discarded
|
|
87
|
+
* attempt, which is why it is opt-in and announced rather than invisible.
|
|
88
|
+
*/
|
|
89
|
+
export interface RetryingEvent {
|
|
90
|
+
type: 'retrying';
|
|
91
|
+
/** 1-based index of the retry about to be issued. */
|
|
92
|
+
attempt: number;
|
|
93
|
+
/** Configured maximum number of retries. */
|
|
94
|
+
maxAttempts: number;
|
|
95
|
+
/** Always 'refusal' today; widened only if other retryable stops appear. */
|
|
96
|
+
reason: 'refusal';
|
|
97
|
+
/** Provider's refusal category when it supplies one (e.g. 'cyber'). */
|
|
98
|
+
category?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
/**
|
|
73
102
|
* Aborted event - stream was cancelled.
|
|
74
103
|
*/
|
|
@@ -87,6 +116,7 @@ export interface AbortedEvent {
|
|
|
87
116
|
export type StreamEvent =
|
|
88
117
|
| TokensEvent
|
|
89
118
|
| StreamBlockEvent
|
|
119
|
+
| RetryingEvent
|
|
90
120
|
| ToolCallsEvent
|
|
91
121
|
| UsageEvent
|
|
92
122
|
| CompleteEvent
|
|
@@ -225,6 +255,23 @@ export interface YieldingStreamOptions {
|
|
|
225
255
|
/** Request ID for correlation/logging */
|
|
226
256
|
requestId?: string;
|
|
227
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Re-issue an attempt that ends with `stop_reason: 'refusal'`, up to this
|
|
260
|
+
* many times. Default 0 (off).
|
|
261
|
+
*
|
|
262
|
+
* Enabling it means the stream can emit `RetryingEvent` — **consumers MUST
|
|
263
|
+
* handle it and discard what they have received for the call**, or two
|
|
264
|
+
* attempts will be concatenated. That is why it is off by default and why
|
|
265
|
+
* turning it on is a per-call decision by a consumer that has been updated.
|
|
266
|
+
*
|
|
267
|
+
* Rationale: near the content-policy threshold a refusal is probabilistic,
|
|
268
|
+
* not a property of the payload — identical bytes pass and refuse minutes
|
|
269
|
+
* apart. Re-asking is cheaper and less invasive than rewriting the
|
|
270
|
+
* conversation, and the replay is cache-warm, so only the discarded output
|
|
271
|
+
* tokens are real spend.
|
|
272
|
+
*/
|
|
273
|
+
refusalRetries?: number;
|
|
274
|
+
|
|
228
275
|
/**
|
|
229
276
|
* Maximum tool execution depth. Default: unlimited.
|
|
230
277
|
*
|
|
@@ -237,6 +284,19 @@ export interface YieldingStreamOptions {
|
|
|
237
284
|
*/
|
|
238
285
|
maxToolDepth?: number;
|
|
239
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Cap on AUTOMATIC false-positive stop-sequence resumptions per turn —
|
|
289
|
+
* membrane's own re-streams, not the caller's tool work. Tool rounds are
|
|
290
|
+
* deliberately NOT counted: this path's uncapped-by-default tool-loop
|
|
291
|
+
* contract stands (the caller budgets its own work via maxToolDepth).
|
|
292
|
+
* What this bounds is membrane's own failure surface — how many times a
|
|
293
|
+
* turn may re-send its full context on membrane's initiative; an
|
|
294
|
+
* unlimited resumption bound is how the 43-round Ash spin happened
|
|
295
|
+
* (issue #39). Exceeding it ends the turn with stopReason 'round_limit'.
|
|
296
|
+
* Default: 24. `-1` for unlimited, at your own risk.
|
|
297
|
+
*/
|
|
298
|
+
maxResumptionRounds?: number;
|
|
299
|
+
|
|
240
300
|
/**
|
|
241
301
|
* Whether to emit 'tokens' events.
|
|
242
302
|
* Set to false if you only care about tool calls and final response.
|