@animalabs/membrane 0.5.67 → 0.5.69

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 (52) hide show
  1. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  2. package/dist/formatters/anthropic-xml.js +14 -8
  3. package/dist/formatters/anthropic-xml.js.map +1 -1
  4. package/dist/formatters/native.d.ts +3 -0
  5. package/dist/formatters/native.d.ts.map +1 -1
  6. package/dist/formatters/native.js +70 -14
  7. package/dist/formatters/native.js.map +1 -1
  8. package/dist/membrane.d.ts.map +1 -1
  9. package/dist/membrane.js +56 -17
  10. package/dist/membrane.js.map +1 -1
  11. package/dist/providers/anthropic-tool-schema.d.ts +43 -0
  12. package/dist/providers/anthropic-tool-schema.d.ts.map +1 -0
  13. package/dist/providers/anthropic-tool-schema.js +165 -0
  14. package/dist/providers/anthropic-tool-schema.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts.map +1 -1
  16. package/dist/providers/anthropic.js +102 -4
  17. package/dist/providers/anthropic.js.map +1 -1
  18. package/dist/providers/gemini.d.ts.map +1 -1
  19. package/dist/providers/gemini.js +15 -0
  20. package/dist/providers/gemini.js.map +1 -1
  21. package/dist/providers/index.d.ts +1 -0
  22. package/dist/providers/index.d.ts.map +1 -1
  23. package/dist/providers/index.js +1 -0
  24. package/dist/providers/index.js.map +1 -1
  25. package/dist/providers/openrouter.d.ts +6 -0
  26. package/dist/providers/openrouter.d.ts.map +1 -1
  27. package/dist/providers/openrouter.js +87 -4
  28. package/dist/providers/openrouter.js.map +1 -1
  29. package/dist/types/provider.d.ts +8 -0
  30. package/dist/types/provider.d.ts.map +1 -1
  31. package/dist/types/request.d.ts +8 -0
  32. package/dist/types/request.d.ts.map +1 -1
  33. package/dist/utils/image-media.d.ts +54 -0
  34. package/dist/utils/image-media.d.ts.map +1 -0
  35. package/dist/utils/image-media.js +135 -0
  36. package/dist/utils/image-media.js.map +1 -0
  37. package/dist/utils/tool-parser.d.ts.map +1 -1
  38. package/dist/utils/tool-parser.js +15 -9
  39. package/dist/utils/tool-parser.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/formatters/anthropic-xml.ts +13 -8
  42. package/src/formatters/native.ts +70 -14
  43. package/src/membrane.ts +60 -17
  44. package/src/providers/anthropic-tool-schema.ts +194 -0
  45. package/src/providers/anthropic.ts +111 -6
  46. package/src/providers/gemini.ts +14 -0
  47. package/src/providers/index.ts +2 -0
  48. package/src/providers/openrouter.ts +94 -5
  49. package/src/types/provider.ts +7 -1
  50. package/src/types/request.ts +8 -0
  51. package/src/utils/image-media.ts +150 -0
  52. package/src/utils/tool-parser.ts +14 -9
@@ -31,7 +31,45 @@ import { safeParseJson, createCombinedSignal, SSELineParser } from './utils.js';
31
31
  /** Content block for messages through OpenRouter */
32
32
  type OpenRouterContentBlock =
33
33
  | { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }
34
- | { type: 'image_url'; image_url: { url: string; detail?: string } };
34
+ | { type: 'image_url'; image_url: { url: string; detail?: string } }
35
+ | { type: 'input_audio'; input_audio: { data: string; format: string } };
36
+
37
+ /**
38
+ * Map a media MIME type to the `format` string OpenRouter expects on an
39
+ * `input_audio` part. OpenRouter follows the OpenAI Chat Completions shape and
40
+ * documents these format values: wav, mp3, aiff, aac, ogg, flac, m4a, pcm16,
41
+ * pcm24. Returns undefined for MIME types we don't have a mapping for, so the
42
+ * caller can skip the block rather than send a malformed part (which model
43
+ * actually accepts which format is the model's concern — this is just plumbing).
44
+ */
45
+ function audioMimeToFormat(mimeType: string): string | undefined {
46
+ // Strip MIME parameters ("audio/mpeg; rate=16000") before matching.
47
+ switch (mimeType.split(';')[0]!.trim().toLowerCase()) {
48
+ case 'audio/mpeg':
49
+ case 'audio/mp3':
50
+ case 'audio/mpeg3': // legacy MP3 aliases some clients (e.g. Discord) report
51
+ case 'audio/x-mpeg-3':
52
+ return 'mp3';
53
+ case 'audio/wav':
54
+ case 'audio/x-wav':
55
+ case 'audio/wave':
56
+ case 'audio/vnd.wave':
57
+ return 'wav';
58
+ case 'audio/ogg':
59
+ return 'ogg';
60
+ case 'audio/flac':
61
+ case 'audio/x-flac':
62
+ return 'flac';
63
+ case 'audio/aac':
64
+ return 'aac';
65
+ case 'audio/mp4':
66
+ case 'audio/m4a':
67
+ case 'audio/x-m4a':
68
+ return 'm4a';
69
+ default:
70
+ return undefined;
71
+ }
72
+ }
35
73
 
36
74
  interface OpenRouterMessage {
37
75
  role: 'user' | 'assistant' | 'system' | 'tool';
@@ -193,8 +231,27 @@ export class OpenRouterAdapter implements ProviderAdapter {
193
231
  for (const data of dataLines) {
194
232
  if (data === '[DONE]') continue;
195
233
 
234
+ // Parse first; only JSON noise is ignorable. Everything after the
235
+ // parse must NOT be swallowed by the catch below.
236
+ let parsed: Record<string, any>;
237
+ try {
238
+ parsed = JSON.parse(data);
239
+ } catch {
240
+ continue; // Ignore parse errors (partial/keep-alive lines)
241
+ }
242
+
243
+ // OpenRouter delivers mid-stream failures (e.g. upstream 429s) as an
244
+ // SSE data line with an `error` payload. Silently ignoring it would
245
+ // yield a fake-successful empty completion — surface it instead so
246
+ // retry logic can handle it.
247
+ if (typeof parsed === 'object' && parsed !== null && parsed.error) {
248
+ const err = parsed.error as { code?: number | string; message?: string };
249
+ throw new Error(
250
+ `OpenRouter stream error${err.code !== undefined ? ` (${err.code})` : ''}: ${err.message ?? JSON.stringify(err)}`
251
+ );
252
+ }
253
+
196
254
  try {
197
- const parsed = JSON.parse(data);
198
255
  const delta = parsed.choices?.[0]?.delta;
199
256
 
200
257
  if (delta?.content) {
@@ -370,10 +427,11 @@ export class OpenRouterAdapter implements ProviderAdapter {
370
427
  const textParts: string[] = [];
371
428
  const toolResults: OpenRouterMessage[] = [];
372
429
  let hasImages = false;
430
+ let hasAudio = false;
373
431
 
374
432
  for (const block of msg.content) {
375
433
  if (block.type === 'text') {
376
- if (hasCache || hasImages) {
434
+ if (hasCache || hasImages || hasAudio) {
377
435
  const contentBlock: OpenRouterContentBlock = {
378
436
  type: 'text',
379
437
  text: block.text,
@@ -406,6 +464,37 @@ export class OpenRouterAdapter implements ProviderAdapter {
406
464
  image_url: { url: block.source.url },
407
465
  });
408
466
  }
467
+ } else if (block.type === 'audio') {
468
+ // Audio input → OpenAI-style `input_audio` content part (sibling to
469
+ // the Gemini inlineData path). Like images, this forces the array
470
+ // content format. MIME (canonical `mediaType`, falling back to
471
+ // snake_case `media_type`, then `audio/mpeg`) is mapped to
472
+ // OpenRouter's `format`; unmappable types are skipped with a warning
473
+ // rather than sending a malformed part. Data is raw base64 (no
474
+ // data-uri prefix), matching the Gemini audio path.
475
+ hasAudio = true;
476
+ // Migrate any already-collected textParts into contentBlocks
477
+ // (we didn't know we'd need array format until hitting audio).
478
+ for (const text of textParts) {
479
+ contentBlocks.push({ type: 'text', text });
480
+ }
481
+ textParts.length = 0;
482
+
483
+ if (block.source?.type === 'base64' && block.source.data) {
484
+ const mediaType = block.source.mediaType ?? block.source.media_type ?? 'audio/mpeg';
485
+ const format = audioMimeToFormat(mediaType);
486
+ if (format) {
487
+ contentBlocks.push({
488
+ type: 'input_audio',
489
+ input_audio: { data: block.source.data, format },
490
+ });
491
+ } else {
492
+ console.warn(
493
+ `[membrane:openrouter] Skipping audio block with unsupported MIME type "${mediaType}" ` +
494
+ `(no OpenRouter input_audio format mapping)`
495
+ );
496
+ }
497
+ }
409
498
  } else if (block.type === 'tool_use') {
410
499
  toolCalls.push({
411
500
  id: block.id,
@@ -435,8 +524,8 @@ export class OpenRouterAdapter implements ProviderAdapter {
435
524
  return [];
436
525
  }
437
526
 
438
- // Use content blocks array if caching or images require it, otherwise concatenate text
439
- const useArray = hasCache || hasImages;
527
+ // Use content blocks array if caching, images, or audio require it, otherwise concatenate text
528
+ const useArray = hasCache || hasImages || hasAudio;
440
529
  const result: OpenRouterMessage = {
441
530
  role: msg.role,
442
531
  content: useArray ? contentBlocks : (textParts.length > 0 ? textParts.join('\n') : null),
@@ -223,7 +223,13 @@ export interface ProviderRequest {
223
223
 
224
224
  /** Tools in provider format */
225
225
  tools?: unknown[];
226
-
226
+
227
+ /**
228
+ * Extended-thinking config (Anthropic). Presence with a `type` other than
229
+ * `'disabled'` enables thinking, which strips custom sampling parameters.
230
+ */
231
+ thinking?: { type?: string; [key: string]: unknown };
232
+
227
233
  /** Additional provider-specific params */
228
234
  extra?: Record<string, unknown>;
229
235
  }
@@ -112,6 +112,14 @@ export type ToolMode =
112
112
  // ============================================================================
113
113
 
114
114
  export interface NormalizedRequest {
115
+ /**
116
+ * Explicitly own the loss of old inline images when the serialized request
117
+ * exceeds the API byte cap: oldest images are replaced with loud
118
+ * placeholders (error-logged). Without this flag an oversize request FAILS
119
+ * LOUDLY before the API call (2026-07-12 — no silent transport-layer
120
+ * mutation). Intended for summarizer/compression callers.
121
+ */
122
+ shedOversizeImages?: boolean;
115
123
  /** Conversation messages */
116
124
  messages: NormalizedMessage[];
117
125
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Image media-type sanitation shared by every request-build path.
3
+ *
4
+ * sharp-readable but API-rejected formats (image/svg, image/tiff, ...) can
5
+ * enter an agent's event store through permissive ingest surfaces; one such
6
+ * block then 400s EVERY subsequent compile (invalid_request) and hard-downs
7
+ * the agent (LabClaude 2026-07-11: an SVG attachment). Every place that turns
8
+ * a normalized image block into a provider image block must either emit an
9
+ * accepted media type or degrade to a loud text placeholder the agent can see.
10
+ */
11
+
12
+ export const API_ACCEPTED_IMAGE_MEDIA_TYPES: ReadonlySet<string> = new Set([
13
+ 'image/jpeg',
14
+ 'image/png',
15
+ 'image/gif',
16
+ 'image/webp',
17
+ ]);
18
+
19
+ export function isAcceptedImageMediaType(mediaType: string | null | undefined): boolean {
20
+ return API_ACCEPTED_IMAGE_MEDIA_TYPES.has((mediaType ?? '').toLowerCase());
21
+ }
22
+
23
+ /** Requests are also capped by SIZE (~32MB serialized) independent of token
24
+ * count — token math is blind to base64 bulk, so an image-heavy window can
25
+ * pass the context budget yet draw 413 request_too_large (Mythos 2026-07-12:
26
+ * 47 inline images = 34.3MB). Default cap leaves headroom for system/tools
27
+ * and JSON overhead; override via MEMBRANE_MAX_REQUEST_BYTES. */
28
+ export const DEFAULT_MAX_REQUEST_BYTES = 28 * 1024 * 1024;
29
+
30
+ /** The effective request byte cap. */
31
+ export function requestByteCap(capBytes?: number): number {
32
+ return capBytes ?? (Number(process.env.MEMBRANE_MAX_REQUEST_BYTES) || DEFAULT_MAX_REQUEST_BYTES);
33
+ }
34
+
35
+ /** Serialized byte size of the messages array (JSON length ≈ wire size). */
36
+ export function serializedMessageBytes(messages: Array<{ content?: unknown }>): number {
37
+ return JSON.stringify(messages).length;
38
+ }
39
+
40
+ /**
41
+ * FAIL LOUDLY when the serialized messages exceed the byte cap (2026-07-12).
42
+ * Silent content mutation at the transport layer is a diagnosis trap: every
43
+ * layer above believes it sent a different context than the wire carried.
44
+ * Callers that can genuinely tolerate losing old images must OWN that policy
45
+ * by setting `shedOversizeImages` on the request — everything else fails
46
+ * here, before the API round-trip, with the full breakdown.
47
+ */
48
+ export function assertWithinByteBudget(
49
+ messages: Array<{ content?: unknown }>,
50
+ capBytes: number | undefined,
51
+ site: string,
52
+ ): void {
53
+ const cap = requestByteCap(capBytes);
54
+ const size = serializedMessageBytes(messages);
55
+ if (size <= cap) return;
56
+ let images = 0;
57
+ let imageBytes = 0;
58
+ const walk = (content: unknown[]): void => {
59
+ for (const b of content) {
60
+ if (!b || typeof b !== 'object') continue;
61
+ const typed = b as { type?: string; content?: unknown };
62
+ if (typed.type === 'image') {
63
+ images++;
64
+ imageBytes += JSON.stringify(b).length;
65
+ } else if (typed.type === 'tool_result' && Array.isArray(typed.content)) {
66
+ walk(typed.content);
67
+ }
68
+ }
69
+ };
70
+ for (const m of messages) if (Array.isArray(m.content)) walk(m.content);
71
+ throw new Error(
72
+ `[membrane] request exceeds the byte cap at ${site}: ${Math.round(size / 1e6)}MB > ` +
73
+ `cap ${Math.round(cap / 1e6)}MB (${images} inline image(s), ~${Math.round(imageBytes / 1e6)}MB of them). ` +
74
+ `Refusing to silently drop content. Either the compile must respect the byte wall ` +
75
+ `(context-manager maxLiveImageBytes), or the caller must explicitly own image loss ` +
76
+ `by setting shedOversizeImages on the request.`,
77
+ );
78
+ }
79
+
80
+ /** Shed inline images, OLDEST first, until the serialized messages fit the
81
+ * byte cap. Mutates content arrays in place, replacing shed image blocks
82
+ * (including ones nested in tool_result content) with loud agent-facing
83
+ * placeholders. Returns the number of images shed.
84
+ *
85
+ * ONLY runs for callers that explicitly opted in (`shedOversizeImages`) —
86
+ * and even then it reports at error grade: an exercised opt-in is a signal
87
+ * the upstream byte wall is misconfigured. */
88
+ export function shedImagesToFitByteBudget(
89
+ messages: Array<{ content?: unknown }>,
90
+ capBytes?: number,
91
+ site = 'unknown-site',
92
+ ): number {
93
+ const cap = requestByteCap(capBytes);
94
+ let size = JSON.stringify(messages).length;
95
+ if (size <= cap) return 0;
96
+
97
+ const placeholder = (): { type: 'text'; text: string } => ({
98
+ type: 'text',
99
+ text:
100
+ `[system: an image that belongs here was dropped from THIS request only — the ` +
101
+ `request exceeded the API's total size limit, and older images are dropped first. ` +
102
+ `You are not seeing this image right now; it remains in your history and recent ` +
103
+ `images are kept.]`,
104
+ });
105
+
106
+ let shed = 0;
107
+ const shedInArray = (content: unknown[]): boolean => {
108
+ for (let i = 0; i < content.length; i++) {
109
+ const b = content[i];
110
+ if (!b || typeof b !== 'object') continue;
111
+ const typed = b as { type?: string; content?: unknown };
112
+ if (typed.type === 'image') {
113
+ const before = JSON.stringify(b).length;
114
+ content[i] = placeholder();
115
+ size -= before - JSON.stringify(content[i]).length;
116
+ shed++;
117
+ if (size <= cap) return true;
118
+ } else if (typed.type === 'tool_result' && Array.isArray(typed.content)) {
119
+ if (shedInArray(typed.content)) return true;
120
+ }
121
+ }
122
+ return false;
123
+ };
124
+
125
+ for (const msg of messages) {
126
+ if (Array.isArray(msg.content) && shedInArray(msg.content)) break;
127
+ }
128
+ console.error(
129
+ `[membrane-oversize] shed ${shed} inline image(s) at ${site} to fit the request byte cap ` +
130
+ `(${Math.round(size / 1e6)}MB / cap ${Math.round(cap / 1e6)}MB). This opt-in firing means ` +
131
+ `the upstream compile exceeded the byte wall — check maxLiveImageBytes / image policy.`,
132
+ );
133
+ return shed;
134
+ }
135
+
136
+ /** Loud agent-facing stand-in for an image the API would reject. The agent
137
+ * must be clearly aware an image was stripped — silence here reads as
138
+ * "there was no image". Also warns on stderr for ops visibility. */
139
+ export function strippedImagePlaceholder(mediaType: unknown): { type: 'text'; text: string } {
140
+ console.warn(
141
+ `[membrane] image block stripped: unsupported media type "${String(mediaType)}"`,
142
+ );
143
+ return {
144
+ type: 'text',
145
+ text:
146
+ `[system: an image that belongs here was NOT shown to you — its media type ` +
147
+ `"${String(mediaType)}" is not accepted by the model API (only jpeg/png/gif/webp are). ` +
148
+ `You are not seeing this image. If it matters, ask for it in a supported format.]`,
149
+ };
150
+ }
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { ToolCall, ToolResult, ParsedToolCalls, ContentBlock, ToolResultContentBlock } from '../types/index.js';
14
+ import { isAcceptedImageMediaType, strippedImagePlaceholder } from './image-media.js';
14
15
 
15
16
  // ============================================================================
16
17
  // Helper Functions
@@ -630,15 +631,19 @@ export function formatToolResultsForSplitTurn(results: ToolResult[]): SplitTurnC
630
631
  if (block.type === 'text') {
631
632
  textParts.push(escapeXml(block.text));
632
633
  } else if (block.type === 'image') {
633
- resultHasImages = true;
634
- resultImages.push({
635
- type: 'image',
636
- source: {
637
- type: 'base64',
638
- media_type: block.source.mediaType,
639
- data: block.source.data,
640
- },
641
- });
634
+ if (!isAcceptedImageMediaType(block.source.mediaType)) {
635
+ textParts.push(escapeXml(strippedImagePlaceholder(block.source.mediaType).text));
636
+ } else {
637
+ resultHasImages = true;
638
+ resultImages.push({
639
+ type: 'image',
640
+ source: {
641
+ type: 'base64',
642
+ media_type: block.source.mediaType,
643
+ data: block.source.data,
644
+ },
645
+ });
646
+ }
642
647
  }
643
648
  }
644
649
  }