@animalabs/membrane 0.3.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/formatters/anthropic-xml.d.ts +63 -0
  2. package/dist/formatters/anthropic-xml.d.ts.map +1 -0
  3. package/dist/formatters/anthropic-xml.js +365 -0
  4. package/dist/formatters/anthropic-xml.js.map +1 -0
  5. package/dist/formatters/completions.d.ts +61 -0
  6. package/dist/formatters/completions.d.ts.map +1 -0
  7. package/dist/formatters/completions.js +224 -0
  8. package/dist/formatters/completions.js.map +1 -0
  9. package/dist/formatters/index.d.ts +8 -0
  10. package/dist/formatters/index.d.ts.map +1 -0
  11. package/dist/formatters/index.js +7 -0
  12. package/dist/formatters/index.js.map +1 -0
  13. package/dist/formatters/native.d.ts +35 -0
  14. package/dist/formatters/native.d.ts.map +1 -0
  15. package/dist/formatters/native.js +261 -0
  16. package/dist/formatters/native.js.map +1 -0
  17. package/dist/formatters/types.d.ts +152 -0
  18. package/dist/formatters/types.d.ts.map +1 -0
  19. package/dist/formatters/types.js +7 -0
  20. package/dist/formatters/types.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/membrane.d.ts +4 -0
  26. package/dist/membrane.d.ts.map +1 -1
  27. package/dist/membrane.js +65 -32
  28. package/dist/membrane.js.map +1 -1
  29. package/dist/providers/anthropic.d.ts.map +1 -1
  30. package/dist/providers/anthropic.js +3 -2
  31. package/dist/providers/anthropic.js.map +1 -1
  32. package/dist/providers/index.d.ts +1 -0
  33. package/dist/providers/index.d.ts.map +1 -1
  34. package/dist/providers/index.js +1 -0
  35. package/dist/providers/index.js.map +1 -1
  36. package/dist/providers/mock.d.ts +82 -0
  37. package/dist/providers/mock.d.ts.map +1 -0
  38. package/dist/providers/mock.js +169 -0
  39. package/dist/providers/mock.js.map +1 -0
  40. package/dist/transforms/index.d.ts +0 -1
  41. package/dist/transforms/index.d.ts.map +1 -1
  42. package/dist/transforms/index.js +2 -1
  43. package/dist/transforms/index.js.map +1 -1
  44. package/dist/types/config.d.ts +7 -0
  45. package/dist/types/config.d.ts.map +1 -1
  46. package/dist/types/config.js.map +1 -1
  47. package/dist/types/streaming.d.ts +6 -0
  48. package/dist/types/streaming.d.ts.map +1 -1
  49. package/dist/utils/tool-parser.d.ts.map +1 -1
  50. package/dist/utils/tool-parser.js +29 -13
  51. package/dist/utils/tool-parser.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/formatters/anthropic-xml.ts +490 -0
  54. package/src/formatters/completions.ts +343 -0
  55. package/src/formatters/index.ts +19 -0
  56. package/src/formatters/native.ts +340 -0
  57. package/src/formatters/types.ts +229 -0
  58. package/src/index.ts +3 -0
  59. package/src/membrane.ts +86 -45
  60. package/src/providers/anthropic.ts +3 -2
  61. package/src/providers/index.ts +7 -0
  62. package/src/providers/mock.ts +234 -0
  63. package/src/transforms/index.ts +2 -10
  64. package/src/types/config.ts +9 -1
  65. package/src/types/streaming.ts +9 -0
  66. package/src/utils/tool-parser.ts +32 -11
  67. package/src/transforms/prefill.ts +0 -574
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Anthropic XML Formatter
3
+ *
4
+ * Prefill-based formatting for Anthropic models using XML tool syntax.
5
+ * This is the "classic" membrane format with:
6
+ * - Participant: content format
7
+ * - <function_calls>/<function_results> for tools
8
+ * - <thinking> blocks for extended thinking
9
+ */
10
+
11
+ import type {
12
+ NormalizedMessage,
13
+ ContentBlock,
14
+ ToolDefinition,
15
+ ToolCall,
16
+ ToolResult,
17
+ } from '../types/index.js';
18
+ import type {
19
+ PrefillFormatter,
20
+ StreamParser,
21
+ BuildOptions,
22
+ BuildResult,
23
+ FormatterConfig,
24
+ ProviderMessage,
25
+ } from './types.js';
26
+ import {
27
+ parseToolCalls as parseToolCallsXml,
28
+ formatToolResults as formatToolResultsXml,
29
+ parseAccumulatedIntoBlocks,
30
+ formatToolDefinitions,
31
+ type ToolDefinitionForPrompt,
32
+ } from '../utils/tool-parser.js';
33
+ import { IncrementalXmlParser } from '../utils/stream-parser.js';
34
+
35
+ // ============================================================================
36
+ // Configuration
37
+ // ============================================================================
38
+
39
+ export interface AnthropicXmlFormatterConfig extends FormatterConfig {
40
+ /**
41
+ * How to handle tool definitions:
42
+ * - 'xml': Inject into conversation as XML (prefill mode)
43
+ * - 'native': Pass to API as native tools
44
+ * Default: 'xml'
45
+ */
46
+ toolMode?: 'xml' | 'native';
47
+
48
+ /**
49
+ * Where to inject tool definitions when toolMode is 'xml':
50
+ * - 'conversation': Inject into assistant content N messages from end
51
+ * - 'system': Inject into system prompt
52
+ * Default: 'conversation'
53
+ */
54
+ toolInjectionMode?: 'conversation' | 'system';
55
+
56
+ /**
57
+ * Position to inject tools (from end of messages).
58
+ * Default: 10
59
+ */
60
+ toolInjectionPosition?: number;
61
+
62
+ /**
63
+ * Message delimiter for base models (e.g., '</s>').
64
+ * Default: '' (none)
65
+ */
66
+ messageDelimiter?: string;
67
+
68
+ /**
69
+ * Maximum participants to include in stop sequences.
70
+ * Default: 10
71
+ */
72
+ maxParticipantsForStop?: number;
73
+ }
74
+
75
+ // ============================================================================
76
+ // Anthropic XML Formatter
77
+ // ============================================================================
78
+
79
+ export class AnthropicXmlFormatter implements PrefillFormatter {
80
+ readonly name = 'anthropic-xml';
81
+ readonly usesPrefill = true;
82
+
83
+ private config: Required<AnthropicXmlFormatterConfig>;
84
+
85
+ constructor(config: AnthropicXmlFormatterConfig = {}) {
86
+ this.config = {
87
+ toolMode: config.toolMode ?? 'xml',
88
+ toolInjectionMode: config.toolInjectionMode ?? 'conversation',
89
+ toolInjectionPosition: config.toolInjectionPosition ?? 10,
90
+ messageDelimiter: config.messageDelimiter ?? '',
91
+ maxParticipantsForStop: config.maxParticipantsForStop ?? 10,
92
+ unsupportedMedia: config.unsupportedMedia ?? 'error',
93
+ warnOnStrip: config.warnOnStrip ?? true,
94
+ };
95
+ }
96
+
97
+ // ==========================================================================
98
+ // REQUEST BUILDING
99
+ // ==========================================================================
100
+
101
+ buildMessages(messages: NormalizedMessage[], options: BuildOptions): BuildResult {
102
+ const {
103
+ assistantParticipant,
104
+ tools,
105
+ thinking,
106
+ systemPrompt,
107
+ promptCaching = false,
108
+ contextPrefix,
109
+ hasCacheMarker,
110
+ } = options;
111
+
112
+ const providerMessages: ProviderMessage[] = [];
113
+ const joiner = this.config.messageDelimiter ? '' : '\n';
114
+
115
+ // Track conversation state
116
+ let currentConversation: string[] = [];
117
+ let lastNonEmptyParticipant: string | null = null;
118
+
119
+ // Track cache marker state - everything BEFORE we see the marker gets cache_control
120
+ let passedCacheMarker = false;
121
+ let cacheMarkersApplied = 0;
122
+
123
+ // Calculate tool injection point
124
+ const totalMessages = messages.length;
125
+ const toolInjectionIndex = Math.max(0, totalMessages - this.config.toolInjectionPosition);
126
+ let toolsInjected = false;
127
+ const hasToolsForConversation =
128
+ this.config.toolMode === 'xml' &&
129
+ this.config.toolInjectionMode === 'conversation' &&
130
+ tools &&
131
+ tools.length > 0;
132
+ const toolsText = hasToolsForConversation ? this.formatToolsForInjection(tools!) : '';
133
+
134
+ // Build system content
135
+ let systemText = typeof systemPrompt === 'string' ? systemPrompt : '';
136
+ if (Array.isArray(systemPrompt)) {
137
+ systemText = systemPrompt
138
+ .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
139
+ .map(b => b.text)
140
+ .join('\n');
141
+ }
142
+
143
+ // Inject tools into system if configured
144
+ if (this.config.toolMode === 'xml' && this.config.toolInjectionMode === 'system' && tools?.length) {
145
+ const toolsXml = this.formatToolDefinitionsXml(tools);
146
+ systemText = this.injectToolsIntoSystem(systemText, toolsXml);
147
+ }
148
+
149
+ // Build system content with optional cache control
150
+ let systemContent: unknown;
151
+ if (systemText) {
152
+ const systemBlock: Record<string, unknown> = { type: 'text', text: systemText };
153
+ if (promptCaching) {
154
+ systemBlock.cache_control = { type: 'ephemeral' };
155
+ cacheMarkersApplied++;
156
+ }
157
+ systemContent = [systemBlock];
158
+ }
159
+
160
+ // Add context prefix as first cached assistant message (for simulacrum seeding)
161
+ if (contextPrefix) {
162
+ const prefixBlock: Record<string, unknown> = { type: 'text', text: contextPrefix };
163
+ if (promptCaching) {
164
+ prefixBlock.cache_control = { type: 'ephemeral' };
165
+ cacheMarkersApplied++;
166
+ }
167
+ providerMessages.push({
168
+ role: 'assistant',
169
+ content: [prefixBlock],
170
+ });
171
+ }
172
+
173
+ // Process messages
174
+ for (let i = 0; i < messages.length; i++) {
175
+ const message = messages[i];
176
+ if (!message) continue;
177
+
178
+ const isLastMessage = i === messages.length - 1;
179
+ const isAssistant = message.participant === assistantParticipant;
180
+
181
+ // Extract content
182
+ const { text, images, hasUnsupportedMedia } = this.extractContent(message.content, message.participant);
183
+ const hasImages = images.length > 0;
184
+ const isEmpty = !text.trim() && !hasImages;
185
+
186
+ // Handle unsupported media
187
+ if (hasUnsupportedMedia) {
188
+ if (this.config.unsupportedMedia === 'error') {
189
+ throw new Error(`AnthropicXmlFormatter does not support media in message from ${message.participant}. Configure unsupportedMedia: 'strip' to ignore.`);
190
+ } else if (this.config.warnOnStrip) {
191
+ console.warn(`[AnthropicXmlFormatter] Stripped unsupported media from message`);
192
+ }
193
+ }
194
+
195
+ // Check for tool results
196
+ const hasToolResult = message.content.some(c => c.type === 'tool_result');
197
+
198
+ // If message has images, flush and add as user turn
199
+ if (hasImages && !isEmpty) {
200
+ if (currentConversation.length > 0) {
201
+ providerMessages.push({
202
+ role: 'assistant',
203
+ content: currentConversation.join(joiner),
204
+ });
205
+ currentConversation = [];
206
+ }
207
+
208
+ const userContent: unknown[] = [];
209
+ if (text) {
210
+ userContent.push({ type: 'text', text: `${message.participant}: ${text}` });
211
+ }
212
+ userContent.push(...images);
213
+
214
+ providerMessages.push({ role: 'user', content: userContent });
215
+ lastNonEmptyParticipant = message.participant;
216
+ continue;
217
+ }
218
+
219
+ // Skip empty messages except last
220
+ if (isEmpty && !isLastMessage) {
221
+ continue;
222
+ }
223
+
224
+ // Check if this message has a cache marker - flush content BEFORE it with cache_control
225
+ if (hasCacheMarker && !passedCacheMarker && hasCacheMarker(message, i)) {
226
+ // Flush everything before this message WITH cache_control (if caching enabled)
227
+ if (currentConversation.length > 0) {
228
+ const content = currentConversation.join(joiner);
229
+ if (promptCaching) {
230
+ const contentBlock: Record<string, unknown> = { type: 'text', text: content };
231
+ contentBlock.cache_control = { type: 'ephemeral' };
232
+ cacheMarkersApplied++;
233
+ providerMessages.push({
234
+ role: 'assistant',
235
+ content: [contentBlock],
236
+ });
237
+ } else {
238
+ providerMessages.push({
239
+ role: 'assistant',
240
+ content: content,
241
+ });
242
+ }
243
+ currentConversation = [];
244
+ }
245
+ passedCacheMarker = true;
246
+ }
247
+
248
+ // Inject tools before this message if at injection point
249
+ const shouldInjectHere = toolInjectionIndex > 0 ? i >= toolInjectionIndex : i === 0;
250
+ if (hasToolsForConversation && !toolsInjected && shouldInjectHere) {
251
+ currentConversation.push(toolsText);
252
+ toolsInjected = true;
253
+ }
254
+
255
+ // Check bot continuation
256
+ const isBotMessage = message.participant === assistantParticipant;
257
+ const isContinuation = isBotMessage && lastNonEmptyParticipant === assistantParticipant && !hasToolResult;
258
+
259
+ if (isContinuation && isLastMessage) {
260
+ // Bot continuation - don't add prefix
261
+ continue;
262
+ } else if (isLastMessage && isEmpty) {
263
+ // Completion target - prefix added below
264
+ } else if (text) {
265
+ currentConversation.push(`${message.participant}: ${text}${this.config.messageDelimiter}`);
266
+ if (!hasToolResult) {
267
+ lastNonEmptyParticipant = message.participant;
268
+ }
269
+ }
270
+ }
271
+
272
+ // Determine turn prefix
273
+ let turnPrefix: string;
274
+ if (thinking?.enabled) {
275
+ turnPrefix = `${assistantParticipant}: <thinking>`;
276
+ } else {
277
+ turnPrefix = `${assistantParticipant}:`;
278
+ }
279
+
280
+ // Flush remaining conversation
281
+ if (hasToolsForConversation && !toolsInjected) {
282
+ currentConversation.push(toolsText);
283
+ }
284
+
285
+ if (currentConversation.length > 0) {
286
+ providerMessages.push({
287
+ role: 'assistant',
288
+ content: [...currentConversation, turnPrefix].join(joiner),
289
+ });
290
+ } else {
291
+ providerMessages.push({
292
+ role: 'assistant',
293
+ content: turnPrefix,
294
+ });
295
+ }
296
+
297
+ // Build stop sequences
298
+ const stopSequences = this.buildStopSequences(messages, assistantParticipant, options);
299
+
300
+ // Native tools if configured
301
+ const nativeTools = this.config.toolMode === 'native' && tools?.length
302
+ ? this.convertToNativeTools(tools)
303
+ : undefined;
304
+
305
+ return {
306
+ messages: providerMessages,
307
+ systemContent,
308
+ assistantPrefill: typeof providerMessages[providerMessages.length - 1]?.content === 'string'
309
+ ? providerMessages[providerMessages.length - 1]!.content as string
310
+ : undefined,
311
+ stopSequences,
312
+ nativeTools,
313
+ cacheMarkersApplied,
314
+ };
315
+ }
316
+
317
+ formatToolResults(results: ToolResult[], options?: { thinking?: boolean }): string {
318
+ let xml = formatToolResultsXml(results);
319
+ if (options?.thinking) {
320
+ xml += '\n<thinking>';
321
+ }
322
+ return xml;
323
+ }
324
+
325
+ // ==========================================================================
326
+ // RESPONSE PARSING
327
+ // ==========================================================================
328
+
329
+ createStreamParser(): StreamParser {
330
+ return new IncrementalXmlParser();
331
+ }
332
+
333
+ parseToolCalls(content: string): ToolCall[] {
334
+ const result = parseToolCallsXml(content);
335
+ return result?.calls ?? [];
336
+ }
337
+
338
+ hasToolUse(content: string): boolean {
339
+ return /<(antml:)?function_calls>/.test(content);
340
+ }
341
+
342
+ parseContentBlocks(content: string): ContentBlock[] {
343
+ const { blocks } = parseAccumulatedIntoBlocks(content);
344
+ return blocks;
345
+ }
346
+
347
+ // ==========================================================================
348
+ // PRIVATE HELPERS
349
+ // ==========================================================================
350
+
351
+ private extractContent(
352
+ content: ContentBlock[],
353
+ participant: string
354
+ ): { text: string; images: unknown[]; hasUnsupportedMedia: boolean } {
355
+ const parts: string[] = [];
356
+ const images: unknown[] = [];
357
+ let hasUnsupportedMedia = false;
358
+
359
+ for (const block of content) {
360
+ if (block.type === 'text') {
361
+ parts.push(block.text);
362
+ } else if (block.type === 'image') {
363
+ if (block.source.type === 'base64') {
364
+ images.push({
365
+ type: 'image',
366
+ source: {
367
+ type: 'base64',
368
+ media_type: block.source.mediaType,
369
+ data: block.source.data,
370
+ },
371
+ });
372
+ }
373
+ } else if (block.type === 'tool_use') {
374
+ parts.push(`${participant}>[${block.name}]: ${JSON.stringify(block.input)}`);
375
+ } else if (block.type === 'tool_result') {
376
+ const resultText = typeof block.content === 'string'
377
+ ? block.content
378
+ : JSON.stringify(block.content);
379
+ parts.push(`${participant}<[tool_result]: ${resultText}`);
380
+ } else if (block.type === 'document' || block.type === 'audio') {
381
+ hasUnsupportedMedia = true;
382
+ }
383
+ }
384
+
385
+ return { text: parts.join('\n'), images, hasUnsupportedMedia };
386
+ }
387
+
388
+ private formatToolDefinitionsXml(tools: ToolDefinition[]): string {
389
+ const toolsForPrompt: ToolDefinitionForPrompt[] = tools.map((tool) => ({
390
+ name: tool.name,
391
+ description: tool.description,
392
+ parameters: Object.fromEntries(
393
+ Object.entries(tool.inputSchema.properties).map(([name, schema]) => [
394
+ name,
395
+ {
396
+ type: schema.type,
397
+ description: schema.description,
398
+ required: tool.inputSchema.required?.includes(name),
399
+ enum: schema.enum,
400
+ },
401
+ ])
402
+ ),
403
+ }));
404
+
405
+ return formatToolDefinitions(toolsForPrompt);
406
+ }
407
+
408
+ private formatToolsForInjection(tools: ToolDefinition[]): string {
409
+ const toolsXml = this.formatToolDefinitionsXml(tools);
410
+
411
+ // Assemble tags to avoid triggering stop sequences
412
+ const FUNC_CALLS_OPEN = '<' + 'function_calls>';
413
+ const FUNC_CALLS_CLOSE = '</' + 'function_calls>';
414
+ const INVOKE_OPEN = '<' + 'invoke name="';
415
+ const INVOKE_CLOSE = '</' + 'invoke>';
416
+ const PARAM_OPEN = '<' + 'parameter name="';
417
+ const PARAM_CLOSE = '</' + 'parameter>';
418
+
419
+ return `
420
+ <available_tools>
421
+ ${toolsXml}
422
+ </available_tools>
423
+
424
+ When you want to use a tool, output:
425
+ ${FUNC_CALLS_OPEN}
426
+ ${INVOKE_OPEN}tool_name">
427
+ ${PARAM_OPEN}param_name">value${PARAM_CLOSE}
428
+ ${INVOKE_CLOSE}
429
+ ${FUNC_CALLS_CLOSE}`;
430
+ }
431
+
432
+ private injectToolsIntoSystem(system: string, toolsXml: string): string {
433
+ const toolsSection = `
434
+ <available_tools>
435
+ ${toolsXml}
436
+ </available_tools>
437
+
438
+ When you want to use a tool, output:
439
+ <function_calls>
440
+ <invoke name="tool_name">
441
+ <parameter name="param_name">value</parameter>
442
+ </invoke>
443
+ </function_calls>
444
+ `;
445
+ return system + '\n\n' + toolsSection;
446
+ }
447
+
448
+ private buildStopSequences(
449
+ messages: NormalizedMessage[],
450
+ assistantName: string,
451
+ options: BuildOptions
452
+ ): string[] {
453
+ const sequences: string[] = [];
454
+
455
+ // Use option's maxParticipantsForStop, falling back to config
456
+ const maxParticipants = options.maxParticipantsForStop ?? this.config.maxParticipantsForStop;
457
+
458
+ // Collect unique participants (excluding assistant)
459
+ const participants = new Set<string>();
460
+ for (let i = messages.length - 1; i >= 0 && participants.size < maxParticipants; i--) {
461
+ const message = messages[i];
462
+ if (message && message.participant !== assistantName) {
463
+ participants.add(message.participant);
464
+ }
465
+ }
466
+
467
+ // Participant-based stops
468
+ for (const participant of participants) {
469
+ sequences.push(`\n${participant}:`);
470
+ }
471
+
472
+ // Tool-related stop
473
+ sequences.push('</function_calls>');
474
+
475
+ // Add any additional stop sequences from options
476
+ if (options.additionalStopSequences?.length) {
477
+ sequences.push(...options.additionalStopSequences);
478
+ }
479
+
480
+ return sequences;
481
+ }
482
+
483
+ private convertToNativeTools(tools: ToolDefinition[]): unknown[] {
484
+ return tools.map(tool => ({
485
+ name: tool.name,
486
+ description: tool.description,
487
+ input_schema: tool.inputSchema,
488
+ }));
489
+ }
490
+ }