@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.
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +14 -8
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/native.d.ts +3 -0
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +70 -14
- package/dist/formatters/native.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +56 -17
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic-tool-schema.d.ts +43 -0
- package/dist/providers/anthropic-tool-schema.d.ts.map +1 -0
- package/dist/providers/anthropic-tool-schema.js +165 -0
- package/dist/providers/anthropic-tool-schema.js.map +1 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +102 -4
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +15 -0
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openrouter.d.ts +6 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +87 -4
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/types/provider.d.ts +8 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +8 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/utils/image-media.d.ts +54 -0
- package/dist/utils/image-media.d.ts.map +1 -0
- package/dist/utils/image-media.js +135 -0
- package/dist/utils/image-media.js.map +1 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +15 -9
- package/dist/utils/tool-parser.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/anthropic-xml.ts +13 -8
- package/src/formatters/native.ts +70 -14
- package/src/membrane.ts +60 -17
- package/src/providers/anthropic-tool-schema.ts +194 -0
- package/src/providers/anthropic.ts +111 -6
- package/src/providers/gemini.ts +14 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/openrouter.ts +94 -5
- package/src/types/provider.ts +7 -1
- package/src/types/request.ts +8 -0
- package/src/utils/image-media.ts +150 -0
- 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
|
|
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),
|
package/src/types/provider.ts
CHANGED
|
@@ -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
|
}
|
package/src/types/request.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/tool-parser.ts
CHANGED
|
@@ -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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
}
|