@animalabs/membrane 0.5.37 → 0.5.38
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/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +11 -9
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +40 -15
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +128 -9
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +17 -1
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/types/provider.d.ts +2 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +6 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/types/tools.d.ts +1 -1
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/types/yielding-stream.d.ts +2 -0
- package/dist/types/yielding-stream.d.ts.map +1 -1
- package/dist/types/yielding-stream.js.map +1 -1
- package/dist/yielding-stream.d.ts.map +1 -1
- package/dist/yielding-stream.js +3 -1
- package/dist/yielding-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/anthropic-xml.ts +13 -11
- package/src/membrane.ts +47 -18
- package/src/providers/anthropic.ts +123 -10
- package/src/providers/bedrock.ts +17 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +7 -0
- package/src/types/tools.ts +1 -1
- package/src/types/yielding-stream.ts +3 -0
- package/src/yielding-stream.ts +3 -1
package/src/membrane.ts
CHANGED
|
@@ -203,14 +203,19 @@ export class Membrane {
|
|
|
203
203
|
if (request.toolMode && request.toolMode !== 'auto') {
|
|
204
204
|
return request.toolMode;
|
|
205
205
|
}
|
|
206
|
-
|
|
207
|
-
// Auto mode: choose based on
|
|
208
|
-
//
|
|
209
|
-
//
|
|
206
|
+
|
|
207
|
+
// Auto mode: choose based on formatter
|
|
208
|
+
// NativeFormatter → native tools via API
|
|
209
|
+
// AnthropicXmlFormatter (default) → XML tools in prefill
|
|
210
|
+
if (this.formatter.name === 'native') {
|
|
211
|
+
return 'native';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Also handle known native-tool providers regardless of formatter
|
|
210
215
|
if (this.adapter.name === 'openrouter') {
|
|
211
216
|
return 'native';
|
|
212
217
|
}
|
|
213
|
-
|
|
218
|
+
|
|
214
219
|
// Default to XML for prefill compatibility
|
|
215
220
|
return 'xml';
|
|
216
221
|
}
|
|
@@ -837,14 +842,17 @@ export class Membrane {
|
|
|
837
842
|
});
|
|
838
843
|
}
|
|
839
844
|
|
|
840
|
-
// Add assistant message with tool use and user message with tool results
|
|
845
|
+
// Add assistant message with tool use and user message with tool results.
|
|
846
|
+
// Use the request's participant name so role mapping is consistent.
|
|
847
|
+
const asstName = request.assistantParticipant
|
|
848
|
+
?? this.config.assistantParticipant ?? 'Claude';
|
|
841
849
|
messages.push({
|
|
842
|
-
participant:
|
|
850
|
+
participant: asstName,
|
|
843
851
|
content: responseBlocks,
|
|
844
852
|
});
|
|
845
853
|
|
|
846
854
|
messages.push({
|
|
847
|
-
participant: 'User',
|
|
855
|
+
participant: asstName === 'Claude' ? 'User' : 'user',
|
|
848
856
|
content: results.map(r => ({
|
|
849
857
|
type: 'tool_result' as const,
|
|
850
858
|
toolUseId: r.toolUseId,
|
|
@@ -923,8 +931,11 @@ export class Membrane {
|
|
|
923
931
|
// Convert messages to provider format
|
|
924
932
|
const providerMessages: any[] = [];
|
|
925
933
|
|
|
934
|
+
const assistantName = request.assistantParticipant
|
|
935
|
+
?? this.config.assistantParticipant ?? 'Claude';
|
|
936
|
+
|
|
926
937
|
for (const msg of messages) {
|
|
927
|
-
const isAssistant = msg.participant ===
|
|
938
|
+
const isAssistant = msg.participant === assistantName;
|
|
928
939
|
const role = isAssistant ? 'assistant' : 'user';
|
|
929
940
|
|
|
930
941
|
// Convert content blocks
|
|
@@ -940,7 +951,7 @@ export class Membrane {
|
|
|
940
951
|
content.push({
|
|
941
952
|
type: 'tool_use',
|
|
942
953
|
id: block.id,
|
|
943
|
-
name: block.name,
|
|
954
|
+
name: sanitizeToolName(block.name),
|
|
944
955
|
input: block.input,
|
|
945
956
|
});
|
|
946
957
|
} else if (block.type === 'tool_result') {
|
|
@@ -972,9 +983,11 @@ export class Membrane {
|
|
|
972
983
|
providerMessages.push({ role, content });
|
|
973
984
|
}
|
|
974
985
|
|
|
975
|
-
// Convert tools to provider format
|
|
986
|
+
// Convert tools to provider format.
|
|
987
|
+
// Native tool names must match ^[a-zA-Z0-9_-]{1,128}$ — sanitize colons
|
|
988
|
+
// from the module:tool namespace convention. Reversed in parseProviderContent.
|
|
976
989
|
const tools = request.tools?.map(tool => ({
|
|
977
|
-
name: tool.name,
|
|
990
|
+
name: sanitizeToolName(tool.name),
|
|
978
991
|
description: tool.description,
|
|
979
992
|
input_schema: tool.inputSchema,
|
|
980
993
|
}));
|
|
@@ -1017,7 +1030,7 @@ export class Membrane {
|
|
|
1017
1030
|
blocks.push({
|
|
1018
1031
|
type: 'tool_use',
|
|
1019
1032
|
id: item.id,
|
|
1020
|
-
name: item.name,
|
|
1033
|
+
name: unsanitizeToolName(item.name),
|
|
1021
1034
|
input: item.input,
|
|
1022
1035
|
});
|
|
1023
1036
|
} else if (item.type === 'thinking') {
|
|
@@ -1071,7 +1084,7 @@ export class Membrane {
|
|
|
1071
1084
|
// Use formatter's buildMessages for all request building
|
|
1072
1085
|
const buildResult = activeFormatter.buildMessages(request.messages, {
|
|
1073
1086
|
participantMode: 'multiuser',
|
|
1074
|
-
assistantParticipant: this.config.assistantParticipant ?? 'Claude',
|
|
1087
|
+
assistantParticipant: request.assistantParticipant ?? this.config.assistantParticipant ?? 'Claude',
|
|
1075
1088
|
tools: request.tools,
|
|
1076
1089
|
thinking: request.config.thinking,
|
|
1077
1090
|
systemPrompt: request.system,
|
|
@@ -1107,7 +1120,7 @@ export class Membrane {
|
|
|
1107
1120
|
private async streamOnce(
|
|
1108
1121
|
request: any,
|
|
1109
1122
|
callbacks: { onChunk: (chunk: string) => void; onContentBlock?: (index: number, block: unknown) => void },
|
|
1110
|
-
options: { signal?: AbortSignal; timeoutMs?: number; onRequest?: (rawRequest: unknown) => void }
|
|
1123
|
+
options: { signal?: AbortSignal; timeoutMs?: number; idleTimeoutMs?: number; onRequest?: (rawRequest: unknown) => void }
|
|
1111
1124
|
) {
|
|
1112
1125
|
return await this.adapter.stream(request, callbacks, options);
|
|
1113
1126
|
}
|
|
@@ -1693,6 +1706,7 @@ export class Membrane {
|
|
|
1693
1706
|
{
|
|
1694
1707
|
signal: stream.signal,
|
|
1695
1708
|
timeoutMs: options.timeoutMs,
|
|
1709
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
1696
1710
|
onRequest: (req: unknown) => { rawRequest = req; },
|
|
1697
1711
|
}
|
|
1698
1712
|
);
|
|
@@ -2070,6 +2084,7 @@ export class Membrane {
|
|
|
2070
2084
|
{
|
|
2071
2085
|
signal: stream.signal,
|
|
2072
2086
|
timeoutMs: options.timeoutMs,
|
|
2087
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
2073
2088
|
onRequest: (req: unknown) => { rawRequest = req; },
|
|
2074
2089
|
}
|
|
2075
2090
|
);
|
|
@@ -2135,14 +2150,16 @@ export class Membrane {
|
|
|
2135
2150
|
});
|
|
2136
2151
|
}
|
|
2137
2152
|
|
|
2138
|
-
// Add messages for next iteration
|
|
2153
|
+
// Add messages for next iteration — use the request's participant names
|
|
2154
|
+
const assistantName = request.assistantParticipant
|
|
2155
|
+
?? this.config.assistantParticipant ?? 'Claude';
|
|
2139
2156
|
messages.push({
|
|
2140
|
-
participant:
|
|
2157
|
+
participant: assistantName,
|
|
2141
2158
|
content: responseBlocks,
|
|
2142
2159
|
});
|
|
2143
2160
|
|
|
2144
2161
|
messages.push({
|
|
2145
|
-
participant: 'User',
|
|
2162
|
+
participant: assistantName === 'Claude' ? 'User' : 'user',
|
|
2146
2163
|
content: results.map(r => ({
|
|
2147
2164
|
type: 'tool_result' as const,
|
|
2148
2165
|
toolUseId: r.toolUseId,
|
|
@@ -2212,3 +2229,15 @@ export class Membrane {
|
|
|
2212
2229
|
}
|
|
2213
2230
|
}
|
|
2214
2231
|
}
|
|
2232
|
+
|
|
2233
|
+
// Native tool names must match ^[a-zA-Z0-9_-]{1,128}$.
|
|
2234
|
+
// The framework uses module:tool namespacing, so we round-trip colons
|
|
2235
|
+
// through an escape encoding for the API wire format.
|
|
2236
|
+
// Lossless: escape underscores first (_u), then encode colons (_c).
|
|
2237
|
+
function sanitizeToolName(name: string): string {
|
|
2238
|
+
return name.replace(/_/g, '_u').replace(/:/g, '_c');
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function unsanitizeToolName(name: string): string {
|
|
2242
|
+
return name.replace(/_c/g, ':').replace(/_u/g, '_');
|
|
2243
|
+
}
|
|
@@ -85,38 +85,151 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
85
85
|
const fullRequest = { ...anthropicRequest, stream: true };
|
|
86
86
|
options?.onRequest?.(fullRequest);
|
|
87
87
|
|
|
88
|
+
// Idle timeout: abort if no SSE event arrives within the deadline.
|
|
89
|
+
// The SDK's timeout only covers the initial HTTP response headers;
|
|
90
|
+
// once streaming starts, a silently dropped connection waits forever.
|
|
91
|
+
const idleMs = options?.idleTimeoutMs ?? 120_000;
|
|
92
|
+
const idleAbort = new AbortController();
|
|
93
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
94
|
+
let idleTimedOut = false;
|
|
95
|
+
|
|
96
|
+
// Link caller's signal so external cancellation still works
|
|
97
|
+
const onExternalAbort = () => idleAbort.abort();
|
|
98
|
+
if (options?.signal) {
|
|
99
|
+
if (options.signal.aborted) { idleAbort.abort(); }
|
|
100
|
+
else { options.signal.addEventListener('abort', onExternalAbort, { once: true }); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const resetIdleTimer = () => {
|
|
104
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
105
|
+
idleTimer = setTimeout(() => { idleTimedOut = true; idleAbort.abort(); }, idleMs);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
resetIdleTimer();
|
|
109
|
+
|
|
88
110
|
try {
|
|
89
111
|
const stream = await this.client.messages.stream(anthropicRequest, {
|
|
90
|
-
signal:
|
|
112
|
+
signal: idleAbort.signal,
|
|
91
113
|
});
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
|
|
115
|
+
// Accumulate response metadata from SSE events directly, so we can
|
|
116
|
+
// skip finalMessage() and its variable-latency connection teardown.
|
|
117
|
+
let model = '';
|
|
118
|
+
let inputTokens = 0;
|
|
119
|
+
let outputTokens = 0;
|
|
120
|
+
let cacheCreationTokens: number | undefined;
|
|
121
|
+
let cacheReadTokens: number | undefined;
|
|
122
|
+
let stopReason: string = 'end_turn';
|
|
123
|
+
let stopSequence: string | undefined;
|
|
124
|
+
|
|
125
|
+
// Content block tracking — finalized on content_block_stop
|
|
126
|
+
const contentBlocks: Record<string, unknown>[] = [];
|
|
95
127
|
let currentBlockIndex = -1;
|
|
128
|
+
let currentBlockContent = '';
|
|
129
|
+
let currentBlockInputJson = '';
|
|
96
130
|
|
|
97
131
|
for await (const event of stream) {
|
|
98
|
-
|
|
132
|
+
resetIdleTimer();
|
|
133
|
+
if (event.type === 'message_start') {
|
|
134
|
+
model = event.message.model;
|
|
135
|
+
const usage = event.message.usage as unknown as Record<string, number>;
|
|
136
|
+
inputTokens = usage.input_tokens ?? 0;
|
|
137
|
+
cacheCreationTokens = usage.cache_creation_input_tokens;
|
|
138
|
+
cacheReadTokens = usage.cache_read_input_tokens;
|
|
139
|
+
|
|
140
|
+
} else if (event.type === 'content_block_start') {
|
|
99
141
|
currentBlockIndex = event.index;
|
|
100
|
-
|
|
142
|
+
currentBlockContent = '';
|
|
143
|
+
currentBlockInputJson = '';
|
|
144
|
+
contentBlocks[currentBlockIndex] = { ...event.content_block };
|
|
101
145
|
callbacks.onContentBlock?.(currentBlockIndex, event.content_block);
|
|
146
|
+
|
|
102
147
|
} else if (event.type === 'content_block_delta') {
|
|
103
148
|
if (event.delta.type === 'text_delta') {
|
|
104
149
|
const chunk = event.delta.text;
|
|
105
|
-
|
|
150
|
+
currentBlockContent += chunk;
|
|
106
151
|
callbacks.onChunk(chunk);
|
|
107
152
|
} else if (event.delta.type === 'thinking_delta') {
|
|
108
|
-
|
|
153
|
+
currentBlockContent += event.delta.thinking;
|
|
109
154
|
callbacks.onChunk(event.delta.thinking);
|
|
155
|
+
} else if ((event.delta as { type: string }).type === 'input_json_delta') {
|
|
156
|
+
currentBlockInputJson += (event.delta as { partial_json: string }).partial_json;
|
|
110
157
|
}
|
|
158
|
+
|
|
111
159
|
} else if (event.type === 'content_block_stop') {
|
|
112
|
-
|
|
160
|
+
// Finalize block — use event.index for defensive correctness
|
|
161
|
+
const blockIdx = (event as { index: number }).index;
|
|
162
|
+
const block = contentBlocks[blockIdx];
|
|
163
|
+
if (block) {
|
|
164
|
+
if (block.type === 'text') {
|
|
165
|
+
block.text = currentBlockContent;
|
|
166
|
+
} else if (block.type === 'thinking') {
|
|
167
|
+
block.thinking = currentBlockContent;
|
|
168
|
+
} else if (block.type === 'tool_use' && currentBlockInputJson) {
|
|
169
|
+
try { block.input = JSON.parse(currentBlockInputJson); } catch { /* partial JSON */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
callbacks.onContentBlock?.(blockIdx, contentBlocks[blockIdx]);
|
|
173
|
+
|
|
174
|
+
} else if (event.type === 'message_delta') {
|
|
175
|
+
// All content blocks are finalized by the time message_delta arrives.
|
|
176
|
+
// Capture final metadata and exit — message_stop and the SSE connection
|
|
177
|
+
// teardown after it add only variable latency with no useful data.
|
|
178
|
+
const delta = event.delta as { stop_reason?: string; stop_sequence?: string };
|
|
179
|
+
stopReason = delta.stop_reason ?? 'end_turn';
|
|
180
|
+
stopSequence = delta.stop_sequence ?? undefined;
|
|
181
|
+
outputTokens = (event.usage as { output_tokens: number }).output_tokens ?? 0;
|
|
182
|
+
break;
|
|
113
183
|
}
|
|
114
184
|
}
|
|
115
185
|
|
|
116
|
-
|
|
117
|
-
|
|
186
|
+
// Clean up idle timer and external signal listener
|
|
187
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
188
|
+
options?.signal?.removeEventListener('abort', onExternalAbort);
|
|
189
|
+
|
|
190
|
+
// Force-close the HTTP connection so we don't block on SSE drain
|
|
191
|
+
try { stream.controller.abort(); } catch { /* already closed */ }
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
content: contentBlocks,
|
|
195
|
+
stopReason,
|
|
196
|
+
stopSequence,
|
|
197
|
+
usage: {
|
|
198
|
+
inputTokens,
|
|
199
|
+
outputTokens,
|
|
200
|
+
cacheCreationTokens,
|
|
201
|
+
cacheReadTokens,
|
|
202
|
+
},
|
|
203
|
+
model,
|
|
204
|
+
rawRequest: fullRequest,
|
|
205
|
+
raw: {
|
|
206
|
+
content: contentBlocks,
|
|
207
|
+
stop_reason: stopReason,
|
|
208
|
+
stop_sequence: stopSequence ?? null,
|
|
209
|
+
model,
|
|
210
|
+
usage: {
|
|
211
|
+
input_tokens: inputTokens,
|
|
212
|
+
output_tokens: outputTokens,
|
|
213
|
+
cache_creation_input_tokens: cacheCreationTokens,
|
|
214
|
+
cache_read_input_tokens: cacheReadTokens,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
};
|
|
118
218
|
|
|
119
219
|
} catch (error) {
|
|
220
|
+
// Clean up timer on error path too
|
|
221
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
222
|
+
options?.signal?.removeEventListener('abort', onExternalAbort);
|
|
223
|
+
|
|
224
|
+
if (idleTimedOut && error instanceof Error && error.name === 'AbortError') {
|
|
225
|
+
throw new MembraneError({
|
|
226
|
+
type: 'timeout',
|
|
227
|
+
message: `SSE stream idle timeout — no events received within ${idleMs}ms`,
|
|
228
|
+
retryable: true,
|
|
229
|
+
rawError: error,
|
|
230
|
+
rawRequest: fullRequest,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
120
233
|
throw this.handleError(error, fullRequest);
|
|
121
234
|
}
|
|
122
235
|
}
|
package/src/providers/bedrock.ts
CHANGED
|
@@ -332,10 +332,26 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
private buildRequest(request: ProviderRequest): BedrockMessageRequest {
|
|
335
|
+
// Strip provider-specific fields (e.g., sourceUrl for Gemini) from image blocks
|
|
336
|
+
// before sending to Bedrock/Anthropic, which rejects extra inputs
|
|
337
|
+
const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
|
|
338
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
339
|
+
return {
|
|
340
|
+
...msg,
|
|
341
|
+
content: msg.content.map((block: any) => {
|
|
342
|
+
if (block.type === 'image' && block.sourceUrl !== undefined) {
|
|
343
|
+
const { sourceUrl, ...rest } = block;
|
|
344
|
+
return rest;
|
|
345
|
+
}
|
|
346
|
+
return block;
|
|
347
|
+
}),
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
|
|
335
351
|
const params: BedrockMessageRequest = {
|
|
336
352
|
anthropic_version: this.anthropicVersion,
|
|
337
353
|
max_tokens: request.maxTokens || this.defaultMaxTokens,
|
|
338
|
-
messages:
|
|
354
|
+
messages: sanitizedMessages as BedrockMessageRequest['messages'],
|
|
339
355
|
};
|
|
340
356
|
|
|
341
357
|
// Handle system prompt
|
package/src/types/provider.ts
CHANGED
|
@@ -228,6 +228,8 @@ export interface ProviderRequest {
|
|
|
228
228
|
export interface ProviderRequestOptions {
|
|
229
229
|
signal?: AbortSignal;
|
|
230
230
|
timeoutMs?: number;
|
|
231
|
+
/** Abort if no SSE event arrives within this many ms (default: 120000) */
|
|
232
|
+
idleTimeoutMs?: number;
|
|
231
233
|
/** Called with the raw API request body right before fetch */
|
|
232
234
|
onRequest?: (rawRequest: unknown) => void;
|
|
233
235
|
}
|
package/src/types/request.ts
CHANGED
|
@@ -154,6 +154,13 @@ export interface NormalizedRequest {
|
|
|
154
154
|
*/
|
|
155
155
|
prefillUserMessage?: string;
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Participant name that maps to the 'assistant' role.
|
|
159
|
+
* Messages with this participant are formatted as assistant turns.
|
|
160
|
+
* Default: 'Claude'
|
|
161
|
+
*/
|
|
162
|
+
assistantParticipant?: string;
|
|
163
|
+
|
|
157
164
|
/** Provider-specific parameters (pass-through) */
|
|
158
165
|
providerParams?: Record<string, unknown>;
|
|
159
166
|
}
|
package/src/types/tools.ts
CHANGED
|
@@ -170,6 +170,9 @@ export interface YieldingStreamOptions {
|
|
|
170
170
|
/** Request timeout (per API call, not total) */
|
|
171
171
|
timeoutMs?: number;
|
|
172
172
|
|
|
173
|
+
/** Abort if no SSE event arrives within this many ms (default: 120000) */
|
|
174
|
+
idleTimeoutMs?: number;
|
|
175
|
+
|
|
173
176
|
/** Request ID for correlation/logging */
|
|
174
177
|
requestId?: string;
|
|
175
178
|
|
package/src/yielding-stream.ts
CHANGED
|
@@ -246,7 +246,9 @@ export class YieldingStreamImpl implements YieldingStream {
|
|
|
246
246
|
this.markDone();
|
|
247
247
|
})
|
|
248
248
|
.catch((error) => {
|
|
249
|
-
this.
|
|
249
|
+
if (!this.isCancelled) {
|
|
250
|
+
this.emit({ type: 'error', error });
|
|
251
|
+
}
|
|
250
252
|
this.markDone();
|
|
251
253
|
});
|
|
252
254
|
}
|