@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.
@@ -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
- this.client = new Anthropic({
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 text = typeof message.content === 'string' ? message.content : message.content.filter(p => p.type === 'text').map(p => p.text!).join('\n');
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 text = typeof message.content === 'string' ? message.content : message.content.filter(p => p.type === 'text').map(p => p.text!).join('\n');
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({