@animalabs/membrane 0.5.66 → 0.5.68
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/native.d.ts.map +1 -1
- package/dist/formatters/native.js +19 -5
- package/dist/formatters/native.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +16 -5
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +10 -6
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +46 -5
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +5 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +58 -3
- package/dist/providers/openai-compatible.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/native.ts +20 -5
- package/src/membrane.ts +17 -5
- package/src/providers/anthropic.ts +56 -7
- package/src/providers/openai-compatible.ts +67 -6
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Anthropic provider adapter
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
+
import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk';
|
|
6
6
|
import type {
|
|
7
7
|
ProviderAdapter,
|
|
8
8
|
ProviderRequest,
|
|
@@ -27,10 +27,20 @@ import {
|
|
|
27
27
|
|
|
28
28
|
export interface AnthropicAdapterConfig {
|
|
29
29
|
/** API key (defaults to ANTHROPIC_API_KEY env var) */
|
|
30
|
-
apiKey?: string;
|
|
30
|
+
apiKey?: string | null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* OAuth/Bearer token (defaults to ANTHROPIC_AUTH_TOKEN env var when the SDK
|
|
34
|
+
* is allowed to resolve environment auth). If explicitly provided, API-key
|
|
35
|
+
* auth is disabled so requests do not send both auth schemes.
|
|
36
|
+
*/
|
|
37
|
+
authToken?: string | null;
|
|
31
38
|
|
|
32
39
|
/** Base URL override */
|
|
33
40
|
baseURL?: string;
|
|
41
|
+
|
|
42
|
+
/** Default headers to include with Anthropic requests */
|
|
43
|
+
defaultHeaders?: ClientOptions['defaultHeaders'];
|
|
34
44
|
|
|
35
45
|
/** Default max tokens */
|
|
36
46
|
defaultMaxTokens?: number;
|
|
@@ -46,10 +56,19 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
46
56
|
private defaultMaxTokens: number;
|
|
47
57
|
|
|
48
58
|
constructor(config: AnthropicAdapterConfig = {}) {
|
|
49
|
-
|
|
50
|
-
apiKey: config.apiKey,
|
|
59
|
+
const clientOptions: ClientOptions = {
|
|
51
60
|
baseURL: config.baseURL,
|
|
52
|
-
|
|
61
|
+
defaultHeaders: config.defaultHeaders,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (config.authToken !== undefined) {
|
|
65
|
+
clientOptions.authToken = config.authToken;
|
|
66
|
+
clientOptions.apiKey = null;
|
|
67
|
+
} else {
|
|
68
|
+
clientOptions.apiKey = config.apiKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.client = new Anthropic(clientOptions);
|
|
53
72
|
this.defaultMaxTokens = config.defaultMaxTokens ?? 4096;
|
|
54
73
|
}
|
|
55
74
|
|
|
@@ -432,6 +451,18 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
432
451
|
if (message.toLowerCase().includes('overloaded')) {
|
|
433
452
|
return serverError(message, 529, error, rawRequest);
|
|
434
453
|
}
|
|
454
|
+
|
|
455
|
+
// Vercel AI Gateway wraps transient upstream outages (a fallback
|
|
456
|
+
// provider 503, routing churn on a sunsetting model) in non-5xx
|
|
457
|
+
// aggregate errors whose body carries gateway routing metadata. The
|
|
458
|
+
// SAME request frequently succeeds on retry once a live provider is
|
|
459
|
+
// picked, so classify these as retryable instead of terminal.
|
|
460
|
+
const gw = message.toLowerCase();
|
|
461
|
+
if (gw.includes("providermetadata") || gw.includes("fallbacksavailable") ||
|
|
462
|
+
gw.includes("modelattempts") || gw.includes("temporarily unavailable") ||
|
|
463
|
+
gw.includes("no_providers_available")) {
|
|
464
|
+
return serverError(message, status ?? 503, error, rawRequest);
|
|
465
|
+
}
|
|
435
466
|
}
|
|
436
467
|
|
|
437
468
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
@@ -482,7 +513,7 @@ function toAnthropicToolResultContent(
|
|
|
482
513
|
type: 'image',
|
|
483
514
|
source: {
|
|
484
515
|
type: 'base64',
|
|
485
|
-
media_type: block.source.mediaType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
|
516
|
+
media_type: detectImageMediaType(block.source.data, block.source.mediaType as string) as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
|
486
517
|
data: block.source.data,
|
|
487
518
|
},
|
|
488
519
|
});
|
|
@@ -501,6 +532,24 @@ function toAnthropicToolResultContent(
|
|
|
501
532
|
* Convert normalized content blocks to Anthropic format
|
|
502
533
|
* Preserves cache_control for prompt caching
|
|
503
534
|
*/
|
|
535
|
+
|
|
536
|
+
/** Detect image media type from the base64 payload's magic bytes. Storage/ingest
|
|
537
|
+
* can lose or mislabel mediaType (e.g. a PNG tagged image/jpeg), which the
|
|
538
|
+
* Anthropic API rejects with a 400. Trust the bytes; fall back to the declared
|
|
539
|
+
* type, then jpeg. */
|
|
540
|
+
function detectImageMediaType(data: string | undefined, fallback?: string): string {
|
|
541
|
+
try {
|
|
542
|
+
const b = Buffer.from((data || "").slice(0, 24), "base64");
|
|
543
|
+
if (b[0]===0x89&&b[1]===0x50&&b[2]===0x4e&&b[3]===0x47) return "image/png";
|
|
544
|
+
if (b[0]===0xff&&b[1]===0xd8&&b[2]===0xff) return "image/jpeg";
|
|
545
|
+
if (b[0]===0x47&&b[1]===0x49&&b[2]===0x46) return "image/gif";
|
|
546
|
+
if (b[0]===0x52&&b[1]===0x49&&b[2]===0x46) return "image/webp";
|
|
547
|
+
} catch {}
|
|
548
|
+
const f = (fallback || "").toLowerCase();
|
|
549
|
+
if (f==="image/jpeg"||f==="image/png"||f==="image/gif"||f==="image/webp") return f;
|
|
550
|
+
return "image/jpeg";
|
|
551
|
+
}
|
|
552
|
+
|
|
504
553
|
export function toAnthropicContent(blocks: ContentBlock[]): Anthropic.ContentBlockParam[] {
|
|
505
554
|
const result: Anthropic.ContentBlockParam[] = [];
|
|
506
555
|
|
|
@@ -522,7 +571,7 @@ export function toAnthropicContent(blocks: ContentBlock[]): Anthropic.ContentBlo
|
|
|
522
571
|
type: 'image',
|
|
523
572
|
source: {
|
|
524
573
|
type: 'base64',
|
|
525
|
-
media_type: block.source.mediaType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
|
574
|
+
media_type: detectImageMediaType(block.source.data, block.source.mediaType as string) as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
|
526
575
|
data: block.source.data,
|
|
527
576
|
},
|
|
528
577
|
});
|
|
@@ -47,6 +47,26 @@ interface OpenAIMessage {
|
|
|
47
47
|
content?: string | OpenAIContentPart[] | null;
|
|
48
48
|
tool_calls?: OpenAIToolCall[];
|
|
49
49
|
tool_call_id?: string;
|
|
50
|
+
/** Reasoning-model trace (OpenRouter et al. deliver this in a separate
|
|
51
|
+
* channel from `content`). Captured into a thinking block, and re-sent on
|
|
52
|
+
* prior assistant turns to preserve chain-of-thought. */
|
|
53
|
+
reasoning?: string;
|
|
54
|
+
reasoning_details?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Some OpenRouter backends (e.g. Parasail, Io Net) spill the tail of the
|
|
59
|
+
* reasoning plus the closing `</think>` into the `content` channel instead of
|
|
60
|
+
* keeping it all in `reasoning`. If a `</think>` appears with no matching
|
|
61
|
+
* `<think>` before it, drop everything up to and including it — that prefix is
|
|
62
|
+
* leaked reasoning, not answer text.
|
|
63
|
+
*/
|
|
64
|
+
function stripOrphanThinkClose(text: string): string {
|
|
65
|
+
const close = text.indexOf('</think>');
|
|
66
|
+
if (close === -1) return text;
|
|
67
|
+
const open = text.indexOf('<think>');
|
|
68
|
+
if (open !== -1 && open < close) return text; // well-formed inline block — leave it
|
|
69
|
+
return text.slice(close + '</think>'.length).replace(/^\s+/, '');
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
interface OpenAIToolCall {
|
|
@@ -178,6 +198,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
178
198
|
const decoder = new TextDecoder();
|
|
179
199
|
const sseParser = new SSELineParser();
|
|
180
200
|
let accumulated = '';
|
|
201
|
+
let reasoning = '';
|
|
181
202
|
let finishReason = 'stop';
|
|
182
203
|
let toolCalls: OpenAIToolCall[] = [];
|
|
183
204
|
|
|
@@ -200,6 +221,11 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
200
221
|
callbacks.onChunk(delta.content);
|
|
201
222
|
}
|
|
202
223
|
|
|
224
|
+
// Reasoning-model trace arrives on its own channel (not `content`).
|
|
225
|
+
if (typeof delta?.reasoning === 'string') {
|
|
226
|
+
reasoning += delta.reasoning;
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
// Handle streaming tool calls
|
|
204
230
|
if (delta?.tool_calls) {
|
|
205
231
|
for (const tc of delta.tool_calls) {
|
|
@@ -234,6 +260,8 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
234
260
|
content: accumulated || null,
|
|
235
261
|
};
|
|
236
262
|
|
|
263
|
+
if (reasoning) message.reasoning = reasoning;
|
|
264
|
+
|
|
237
265
|
if (toolCalls.length > 0) {
|
|
238
266
|
message.tool_calls = toolCalls;
|
|
239
267
|
}
|
|
@@ -336,10 +364,18 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
336
364
|
const contentParts: OpenAIContentPart[] = [];
|
|
337
365
|
const toolCalls: OpenAIToolCall[] = [];
|
|
338
366
|
const toolResults: OpenAIMessage[] = [];
|
|
367
|
+
let reasoningText = '';
|
|
339
368
|
|
|
340
369
|
for (const block of msg.content) {
|
|
341
370
|
if (block.type === 'text') {
|
|
342
371
|
contentParts.push({ type: 'text', text: block.text });
|
|
372
|
+
} else if (block.type === 'thinking') {
|
|
373
|
+
// Round-trip the reasoning trace back to the provider (OpenRouter
|
|
374
|
+
// accepts `reasoning` on a prior assistant turn), mirroring how the
|
|
375
|
+
// Anthropic adapter re-feeds signed thinking blocks.
|
|
376
|
+
if (typeof block.thinking === 'string') {
|
|
377
|
+
reasoningText += (reasoningText ? '\n' : '') + block.thinking;
|
|
378
|
+
}
|
|
343
379
|
} else if (block.type === 'image') {
|
|
344
380
|
// Convert Anthropic-style image to OpenAI image_url with data URI
|
|
345
381
|
if (block.source?.type === 'base64') {
|
|
@@ -396,6 +432,10 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
396
432
|
result.tool_calls = toolCalls;
|
|
397
433
|
}
|
|
398
434
|
|
|
435
|
+
if (reasoningText) {
|
|
436
|
+
result.reasoning = reasoningText;
|
|
437
|
+
}
|
|
438
|
+
|
|
399
439
|
return [result];
|
|
400
440
|
}
|
|
401
441
|
|
|
@@ -486,11 +526,18 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
486
526
|
|
|
487
527
|
const content: ContentBlock[] = [];
|
|
488
528
|
|
|
529
|
+
// Reasoning trace first (mirrors Anthropic thinking-block ordering).
|
|
530
|
+
const reasoning = (message as OpenAIMessage).reasoning;
|
|
531
|
+
if (typeof reasoning === 'string' && reasoning.trim()) {
|
|
532
|
+
content.push({ type: 'thinking', thinking: reasoning } as ContentBlock);
|
|
533
|
+
}
|
|
534
|
+
|
|
489
535
|
if (message.content) {
|
|
490
|
-
const
|
|
536
|
+
const raw = typeof message.content === 'string' ? message.content : message.content.filter(p => p.type === 'text').map(p => p.text!).join('\n');
|
|
537
|
+
const text = stripOrphanThinkClose(raw);
|
|
491
538
|
if (text) content.push({ type: 'text', text });
|
|
492
539
|
}
|
|
493
|
-
|
|
540
|
+
|
|
494
541
|
if (message.tool_calls) {
|
|
495
542
|
for (const tc of message.tool_calls) {
|
|
496
543
|
content.push({
|
|
@@ -575,10 +622,15 @@ export function toOpenAIMessages(
|
|
|
575
622
|
const textParts: string[] = [];
|
|
576
623
|
const toolCalls: OpenAIToolCall[] = [];
|
|
577
624
|
const toolResults: { id: string; content: string }[] = [];
|
|
578
|
-
|
|
625
|
+
let reasoningText = '';
|
|
626
|
+
|
|
579
627
|
for (const block of msg.content) {
|
|
580
628
|
if (block.type === 'text') {
|
|
581
629
|
textParts.push(block.text);
|
|
630
|
+
} else if (block.type === 'thinking') {
|
|
631
|
+
if (typeof (block as { thinking?: string }).thinking === 'string') {
|
|
632
|
+
reasoningText += (reasoningText ? '\n' : '') + (block as { thinking: string }).thinking;
|
|
633
|
+
}
|
|
582
634
|
} else if (block.type === 'tool_use') {
|
|
583
635
|
toolCalls.push({
|
|
584
636
|
id: block.id,
|
|
@@ -597,7 +649,7 @@ export function toOpenAIMessages(
|
|
|
597
649
|
}
|
|
598
650
|
|
|
599
651
|
// Add main message
|
|
600
|
-
if (textParts.length > 0 || toolCalls.length > 0) {
|
|
652
|
+
if (textParts.length > 0 || toolCalls.length > 0 || reasoningText) {
|
|
601
653
|
const message: OpenAIMessage = {
|
|
602
654
|
role: msg.role as 'user' | 'assistant',
|
|
603
655
|
content: textParts.join('\n') || null,
|
|
@@ -605,6 +657,9 @@ export function toOpenAIMessages(
|
|
|
605
657
|
if (toolCalls.length > 0) {
|
|
606
658
|
message.tool_calls = toolCalls;
|
|
607
659
|
}
|
|
660
|
+
if (reasoningText) {
|
|
661
|
+
message.reasoning = reasoningText;
|
|
662
|
+
}
|
|
608
663
|
result.push(message);
|
|
609
664
|
}
|
|
610
665
|
|
|
@@ -627,11 +682,17 @@ export function toOpenAIMessages(
|
|
|
627
682
|
export function fromOpenAIMessage(message: OpenAIMessage): ContentBlock[] {
|
|
628
683
|
const result: ContentBlock[] = [];
|
|
629
684
|
|
|
685
|
+
const reasoning = message.reasoning;
|
|
686
|
+
if (typeof reasoning === 'string' && reasoning.trim()) {
|
|
687
|
+
result.push({ type: 'thinking', thinking: reasoning } as ContentBlock);
|
|
688
|
+
}
|
|
689
|
+
|
|
630
690
|
if (message.content) {
|
|
631
|
-
const
|
|
691
|
+
const raw = typeof message.content === 'string' ? message.content : message.content.filter(p => p.type === 'text').map(p => p.text!).join('\n');
|
|
692
|
+
const text = stripOrphanThinkClose(raw);
|
|
632
693
|
if (text) result.push({ type: 'text', text });
|
|
633
694
|
}
|
|
634
|
-
|
|
695
|
+
|
|
635
696
|
if (message.tool_calls) {
|
|
636
697
|
for (const tc of message.tool_calls) {
|
|
637
698
|
result.push({
|