@animalabs/membrane 0.1.0

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 (131) hide show
  1. package/dist/context/index.d.ts +10 -0
  2. package/dist/context/index.d.ts.map +1 -0
  3. package/dist/context/index.js +9 -0
  4. package/dist/context/index.js.map +1 -0
  5. package/dist/context/process.d.ts +22 -0
  6. package/dist/context/process.d.ts.map +1 -0
  7. package/dist/context/process.js +369 -0
  8. package/dist/context/process.js.map +1 -0
  9. package/dist/context/types.d.ts +118 -0
  10. package/dist/context/types.d.ts.map +1 -0
  11. package/dist/context/types.js +60 -0
  12. package/dist/context/types.js.map +1 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +18 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/membrane.d.ts +96 -0
  18. package/dist/membrane.d.ts.map +1 -0
  19. package/dist/membrane.js +893 -0
  20. package/dist/membrane.js.map +1 -0
  21. package/dist/providers/anthropic.d.ts +36 -0
  22. package/dist/providers/anthropic.d.ts.map +1 -0
  23. package/dist/providers/anthropic.js +265 -0
  24. package/dist/providers/anthropic.js.map +1 -0
  25. package/dist/providers/index.d.ts +8 -0
  26. package/dist/providers/index.d.ts.map +1 -0
  27. package/dist/providers/index.js +8 -0
  28. package/dist/providers/index.js.map +1 -0
  29. package/dist/providers/openai-compatible.d.ts +74 -0
  30. package/dist/providers/openai-compatible.d.ts.map +1 -0
  31. package/dist/providers/openai-compatible.js +412 -0
  32. package/dist/providers/openai-compatible.js.map +1 -0
  33. package/dist/providers/openai.d.ts +69 -0
  34. package/dist/providers/openai.d.ts.map +1 -0
  35. package/dist/providers/openai.js +455 -0
  36. package/dist/providers/openai.js.map +1 -0
  37. package/dist/providers/openrouter.d.ts +76 -0
  38. package/dist/providers/openrouter.d.ts.map +1 -0
  39. package/dist/providers/openrouter.js +492 -0
  40. package/dist/providers/openrouter.js.map +1 -0
  41. package/dist/transforms/chat.d.ts +52 -0
  42. package/dist/transforms/chat.d.ts.map +1 -0
  43. package/dist/transforms/chat.js +136 -0
  44. package/dist/transforms/chat.js.map +1 -0
  45. package/dist/transforms/index.d.ts +6 -0
  46. package/dist/transforms/index.d.ts.map +1 -0
  47. package/dist/transforms/index.js +6 -0
  48. package/dist/transforms/index.js.map +1 -0
  49. package/dist/transforms/prefill.d.ts +89 -0
  50. package/dist/transforms/prefill.d.ts.map +1 -0
  51. package/dist/transforms/prefill.js +401 -0
  52. package/dist/transforms/prefill.js.map +1 -0
  53. package/dist/types/config.d.ts +103 -0
  54. package/dist/types/config.d.ts.map +1 -0
  55. package/dist/types/config.js +21 -0
  56. package/dist/types/config.js.map +1 -0
  57. package/dist/types/content.d.ts +81 -0
  58. package/dist/types/content.d.ts.map +1 -0
  59. package/dist/types/content.js +40 -0
  60. package/dist/types/content.js.map +1 -0
  61. package/dist/types/errors.d.ts +42 -0
  62. package/dist/types/errors.d.ts.map +1 -0
  63. package/dist/types/errors.js +208 -0
  64. package/dist/types/errors.js.map +1 -0
  65. package/dist/types/index.d.ts +18 -0
  66. package/dist/types/index.d.ts.map +1 -0
  67. package/dist/types/index.js +9 -0
  68. package/dist/types/index.js.map +1 -0
  69. package/dist/types/message.d.ts +46 -0
  70. package/dist/types/message.d.ts.map +1 -0
  71. package/dist/types/message.js +38 -0
  72. package/dist/types/message.js.map +1 -0
  73. package/dist/types/provider.d.ts +155 -0
  74. package/dist/types/provider.d.ts.map +1 -0
  75. package/dist/types/provider.js +5 -0
  76. package/dist/types/provider.js.map +1 -0
  77. package/dist/types/request.d.ts +78 -0
  78. package/dist/types/request.d.ts.map +1 -0
  79. package/dist/types/request.js +5 -0
  80. package/dist/types/request.js.map +1 -0
  81. package/dist/types/response.d.ts +131 -0
  82. package/dist/types/response.d.ts.map +1 -0
  83. package/dist/types/response.js +7 -0
  84. package/dist/types/response.js.map +1 -0
  85. package/dist/types/streaming.d.ts +164 -0
  86. package/dist/types/streaming.d.ts.map +1 -0
  87. package/dist/types/streaming.js +5 -0
  88. package/dist/types/streaming.js.map +1 -0
  89. package/dist/types/tools.d.ts +71 -0
  90. package/dist/types/tools.d.ts.map +1 -0
  91. package/dist/types/tools.js +5 -0
  92. package/dist/types/tools.js.map +1 -0
  93. package/dist/utils/index.d.ts +5 -0
  94. package/dist/utils/index.d.ts.map +1 -0
  95. package/dist/utils/index.js +5 -0
  96. package/dist/utils/index.js.map +1 -0
  97. package/dist/utils/stream-parser.d.ts +53 -0
  98. package/dist/utils/stream-parser.d.ts.map +1 -0
  99. package/dist/utils/stream-parser.js +359 -0
  100. package/dist/utils/stream-parser.js.map +1 -0
  101. package/dist/utils/tool-parser.d.ts +130 -0
  102. package/dist/utils/tool-parser.d.ts.map +1 -0
  103. package/dist/utils/tool-parser.js +571 -0
  104. package/dist/utils/tool-parser.js.map +1 -0
  105. package/package.json +37 -0
  106. package/src/context/index.ts +24 -0
  107. package/src/context/process.ts +520 -0
  108. package/src/context/types.ts +231 -0
  109. package/src/index.ts +23 -0
  110. package/src/membrane.ts +1174 -0
  111. package/src/providers/anthropic.ts +340 -0
  112. package/src/providers/index.ts +31 -0
  113. package/src/providers/openai-compatible.ts +570 -0
  114. package/src/providers/openai.ts +625 -0
  115. package/src/providers/openrouter.ts +662 -0
  116. package/src/transforms/chat.ts +212 -0
  117. package/src/transforms/index.ts +22 -0
  118. package/src/transforms/prefill.ts +585 -0
  119. package/src/types/config.ts +172 -0
  120. package/src/types/content.ts +181 -0
  121. package/src/types/errors.ts +277 -0
  122. package/src/types/index.ts +154 -0
  123. package/src/types/message.ts +89 -0
  124. package/src/types/provider.ts +249 -0
  125. package/src/types/request.ts +131 -0
  126. package/src/types/response.ts +223 -0
  127. package/src/types/streaming.ts +231 -0
  128. package/src/types/tools.ts +92 -0
  129. package/src/utils/index.ts +15 -0
  130. package/src/utils/stream-parser.ts +440 -0
  131. package/src/utils/tool-parser.ts +715 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * OpenRouter provider adapter
3
+ *
4
+ * Handles OpenAI-compatible API with tool_calls format
5
+ */
6
+
7
+ import type {
8
+ ProviderAdapter,
9
+ ProviderRequest,
10
+ ProviderRequestOptions,
11
+ ProviderResponse,
12
+ StreamCallbacks,
13
+ ContentBlock,
14
+ ToolDefinition,
15
+ } from '../types/index.js';
16
+ import {
17
+ MembraneError,
18
+ rateLimitError,
19
+ contextLengthError,
20
+ authError,
21
+ serverError,
22
+ abortError,
23
+ networkError,
24
+ } from '../types/index.js';
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ /** Content block for Anthropic-style caching through OpenRouter */
31
+ interface OpenRouterContentBlock {
32
+ type: 'text';
33
+ text: string;
34
+ cache_control?: { type: 'ephemeral' };
35
+ }
36
+
37
+ interface OpenRouterMessage {
38
+ role: 'user' | 'assistant' | 'system' | 'tool';
39
+ /** Can be string, null, or content blocks array (for Claude cache_control) */
40
+ content?: string | null | OpenRouterContentBlock[];
41
+ tool_calls?: OpenRouterToolCall[];
42
+ tool_call_id?: string;
43
+ }
44
+
45
+ interface OpenRouterToolCall {
46
+ id: string;
47
+ type: 'function';
48
+ function: {
49
+ name: string;
50
+ arguments: string;
51
+ };
52
+ }
53
+
54
+ interface OpenRouterTool {
55
+ type: 'function';
56
+ function: {
57
+ name: string;
58
+ description: string;
59
+ parameters: Record<string, unknown>;
60
+ };
61
+ }
62
+
63
+ interface OpenRouterResponse {
64
+ id: string;
65
+ model: string;
66
+ choices: {
67
+ index: number;
68
+ message: OpenRouterMessage;
69
+ finish_reason: string;
70
+ }[];
71
+ usage: {
72
+ prompt_tokens: number;
73
+ completion_tokens: number;
74
+ total_tokens: number;
75
+ /** Anthropic prompt caching (when using Claude models with cache_control) */
76
+ cache_creation_input_tokens?: number;
77
+ cache_read_input_tokens?: number;
78
+ /** OpenAI-style prompt caching details */
79
+ prompt_tokens_details?: {
80
+ cached_tokens?: number;
81
+ };
82
+ };
83
+ }
84
+
85
+ // ============================================================================
86
+ // Adapter Configuration
87
+ // ============================================================================
88
+
89
+ export interface OpenRouterAdapterConfig {
90
+ /** API key (defaults to OPENROUTER_API_KEY env var) */
91
+ apiKey?: string;
92
+
93
+ /** Base URL (default: https://openrouter.ai/api/v1) */
94
+ baseURL?: string;
95
+
96
+ /** HTTP Referer header for OpenRouter */
97
+ httpReferer?: string;
98
+
99
+ /** X-Title header for OpenRouter */
100
+ xTitle?: string;
101
+
102
+ /** Default max tokens */
103
+ defaultMaxTokens?: number;
104
+ }
105
+
106
+ // ============================================================================
107
+ // OpenRouter Adapter
108
+ // ============================================================================
109
+
110
+ export class OpenRouterAdapter implements ProviderAdapter {
111
+ readonly name = 'openrouter';
112
+ private apiKey: string;
113
+ private baseURL: string;
114
+ private httpReferer: string;
115
+ private xTitle: string;
116
+ private defaultMaxTokens: number;
117
+
118
+ constructor(config: OpenRouterAdapterConfig = {}) {
119
+ this.apiKey = config.apiKey ?? process.env.OPENROUTER_API_KEY ?? '';
120
+ this.baseURL = config.baseURL ?? 'https://openrouter.ai/api/v1';
121
+ this.httpReferer = config.httpReferer ?? 'https://membrane.local';
122
+ this.xTitle = config.xTitle ?? 'Membrane';
123
+ this.defaultMaxTokens = config.defaultMaxTokens ?? 4096;
124
+
125
+ if (!this.apiKey) {
126
+ throw new Error('OpenRouter API key not provided');
127
+ }
128
+ }
129
+
130
+ supportsModel(modelId: string): boolean {
131
+ // OpenRouter supports many models
132
+ return modelId.includes('/');
133
+ }
134
+
135
+ async complete(
136
+ request: ProviderRequest,
137
+ options?: ProviderRequestOptions
138
+ ): Promise<ProviderResponse> {
139
+ const openRouterRequest = this.buildRequest(request);
140
+
141
+ try {
142
+ const response = await this.makeRequest(openRouterRequest, options);
143
+ return this.parseResponse(response, request.model);
144
+ } catch (error) {
145
+ throw this.handleError(error);
146
+ }
147
+ }
148
+
149
+ async stream(
150
+ request: ProviderRequest,
151
+ callbacks: StreamCallbacks,
152
+ options?: ProviderRequestOptions
153
+ ): Promise<ProviderResponse> {
154
+ const openRouterRequest = this.buildRequest(request);
155
+ openRouterRequest.stream = true;
156
+ // Request usage data in stream for cache metrics
157
+ openRouterRequest.stream_options = { include_usage: true };
158
+
159
+ try {
160
+ const response = await fetch(`${this.baseURL}/chat/completions`, {
161
+ method: 'POST',
162
+ headers: this.getHeaders(),
163
+ body: JSON.stringify(openRouterRequest),
164
+ signal: options?.signal,
165
+ });
166
+
167
+ if (!response.ok) {
168
+ const errorText = await response.text();
169
+ throw new Error(`OpenRouter error: ${response.status} ${errorText}`);
170
+ }
171
+
172
+ const reader = response.body?.getReader();
173
+ if (!reader) {
174
+ throw new Error('No response body');
175
+ }
176
+
177
+ const decoder = new TextDecoder();
178
+ let accumulated = '';
179
+ let finishReason = 'stop';
180
+ let toolCalls: OpenRouterToolCall[] = [];
181
+ let streamUsage: OpenRouterResponse['usage'] | undefined;
182
+
183
+ while (true) {
184
+ const { done, value } = await reader.read();
185
+ if (done) break;
186
+
187
+ const chunk = decoder.decode(value, { stream: true });
188
+ const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
189
+
190
+ for (const line of lines) {
191
+ const data = line.slice(6);
192
+ if (data === '[DONE]') continue;
193
+
194
+ try {
195
+ const parsed = JSON.parse(data);
196
+ const delta = parsed.choices?.[0]?.delta;
197
+
198
+ if (delta?.content) {
199
+ accumulated += delta.content;
200
+ callbacks.onChunk(delta.content);
201
+ }
202
+
203
+ // Handle streaming tool calls
204
+ if (delta?.tool_calls) {
205
+ for (const tc of delta.tool_calls) {
206
+ const index = tc.index ?? 0;
207
+ if (!toolCalls[index]) {
208
+ toolCalls[index] = {
209
+ id: tc.id ?? '',
210
+ type: 'function',
211
+ function: { name: '', arguments: '' },
212
+ };
213
+ }
214
+ if (tc.id) toolCalls[index].id = tc.id;
215
+ if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
216
+ if (tc.function?.arguments) {
217
+ toolCalls[index].function.arguments += tc.function.arguments;
218
+ }
219
+ }
220
+ }
221
+
222
+ if (parsed.choices?.[0]?.finish_reason) {
223
+ finishReason = parsed.choices[0].finish_reason;
224
+ }
225
+
226
+ // Capture usage data (comes in final chunk when stream_options.include_usage is set)
227
+ if (parsed.usage) {
228
+ streamUsage = parsed.usage;
229
+ }
230
+ } catch (e) {
231
+ // Ignore parse errors
232
+ }
233
+ }
234
+ }
235
+
236
+ // Build response with accumulated data
237
+ const message: OpenRouterMessage = {
238
+ role: 'assistant',
239
+ content: accumulated || null,
240
+ };
241
+
242
+ if (toolCalls.length > 0) {
243
+ message.tool_calls = toolCalls;
244
+ }
245
+
246
+ return this.parseStreamedResponse(message, finishReason, request.model, streamUsage);
247
+
248
+ } catch (error) {
249
+ throw this.handleError(error);
250
+ }
251
+ }
252
+
253
+ private getHeaders(): Record<string, string> {
254
+ return {
255
+ 'Authorization': `Bearer ${this.apiKey}`,
256
+ 'Content-Type': 'application/json',
257
+ 'HTTP-Referer': this.httpReferer,
258
+ 'X-Title': this.xTitle,
259
+ };
260
+ }
261
+
262
+ private buildRequest(request: ProviderRequest): any {
263
+ const messages = this.convertMessages(request.messages as any[]);
264
+
265
+ const params: any = {
266
+ model: request.model,
267
+ messages,
268
+ max_tokens: request.maxTokens || this.defaultMaxTokens,
269
+ };
270
+
271
+ // Handle system prompt (can be string or content blocks with cache_control)
272
+ if (request.system) {
273
+ if (typeof request.system === 'string') {
274
+ // Simple string system prompt - prepend as system message
275
+ messages.unshift({ role: 'system' as const, content: request.system });
276
+ } else if (Array.isArray(request.system)) {
277
+ // Content blocks with potential cache_control - preserve for Claude caching
278
+ const hasCache = (request.system as any[]).some((block: any) => block.cache_control);
279
+ if (hasCache) {
280
+ // Preserve cache_control in content block format for Claude
281
+ messages.unshift({
282
+ role: 'system' as const,
283
+ content: request.system as unknown as OpenRouterContentBlock[],
284
+ });
285
+ } else {
286
+ // No caching, just join text
287
+ const text = (request.system as any[])
288
+ .filter((b: any) => b.type === 'text')
289
+ .map((b: any) => b.text)
290
+ .join('\n');
291
+ messages.unshift({ role: 'system' as const, content: text });
292
+ }
293
+ }
294
+ }
295
+
296
+ if (request.temperature !== undefined) {
297
+ params.temperature = request.temperature;
298
+ }
299
+
300
+ if (request.stopSequences && request.stopSequences.length > 0) {
301
+ params.stop = request.stopSequences;
302
+ }
303
+
304
+ if (request.tools && request.tools.length > 0) {
305
+ // Check if tools are already in OpenRouter format (from buildNativeToolRequest)
306
+ const firstTool = request.tools[0] as any;
307
+ if (firstTool.input_schema) {
308
+ // Already in provider format
309
+ params.tools = request.tools.map((t: any) => ({
310
+ type: 'function',
311
+ function: {
312
+ name: t.name,
313
+ description: t.description,
314
+ parameters: t.input_schema,
315
+ },
316
+ }));
317
+ } else if (firstTool.inputSchema) {
318
+ // In ToolDefinition format
319
+ params.tools = this.convertTools(request.tools as ToolDefinition[]);
320
+ } else {
321
+ // Unknown format, pass through
322
+ params.tools = request.tools;
323
+ }
324
+ }
325
+
326
+ // Apply extra params
327
+ if (request.extra) {
328
+ Object.assign(params, request.extra);
329
+ }
330
+
331
+ return params;
332
+ }
333
+
334
+ private convertMessages(messages: any[]): OpenRouterMessage[] {
335
+ // Use flatMap to handle one-to-many expansion (multiple tool_results → multiple messages)
336
+ return messages.flatMap(msg => {
337
+ // If it's already in OpenRouter format, pass through
338
+ if (msg.role && (typeof msg.content === 'string' || msg.content === null || msg.tool_calls)) {
339
+ return [msg as OpenRouterMessage];
340
+ }
341
+
342
+ // Convert from Anthropic-style format
343
+ if (Array.isArray(msg.content)) {
344
+ // Check if any block has cache_control - if so, preserve the array format
345
+ // This is needed for Claude models through OpenRouter to use prompt caching
346
+ const hasCache = msg.content.some((block: any) => block.cache_control);
347
+
348
+ const toolCalls: OpenRouterToolCall[] = [];
349
+ const contentBlocks: OpenRouterContentBlock[] = [];
350
+ const textParts: string[] = [];
351
+ const toolResults: OpenRouterMessage[] = [];
352
+
353
+ for (const block of msg.content) {
354
+ if (block.type === 'text') {
355
+ if (hasCache) {
356
+ // Preserve cache_control in content block format
357
+ const contentBlock: OpenRouterContentBlock = {
358
+ type: 'text',
359
+ text: block.text,
360
+ };
361
+ if (block.cache_control) {
362
+ contentBlock.cache_control = block.cache_control;
363
+ }
364
+ contentBlocks.push(contentBlock);
365
+ } else {
366
+ textParts.push(block.text);
367
+ }
368
+ } else if (block.type === 'tool_use') {
369
+ toolCalls.push({
370
+ id: block.id,
371
+ type: 'function',
372
+ function: {
373
+ name: block.name,
374
+ arguments: JSON.stringify(block.input),
375
+ },
376
+ });
377
+ } else if (block.type === 'tool_result') {
378
+ // Collect ALL tool results - each becomes a separate message
379
+ toolResults.push({
380
+ role: 'tool' as const,
381
+ tool_call_id: block.tool_use_id || block.toolUseId,
382
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
383
+ });
384
+ }
385
+ }
386
+
387
+ // If we have tool results, return them (possibly multiple)
388
+ if (toolResults.length > 0) {
389
+ return toolResults;
390
+ }
391
+
392
+ // Otherwise build normal message
393
+ const result: OpenRouterMessage = {
394
+ role: msg.role,
395
+ // Use content blocks array if caching is in use, otherwise concatenate text
396
+ content: hasCache ? contentBlocks : (textParts.join('\n') || null),
397
+ };
398
+
399
+ if (toolCalls.length > 0) {
400
+ result.tool_calls = toolCalls;
401
+ }
402
+
403
+ return [result];
404
+ }
405
+
406
+ return [{
407
+ role: msg.role,
408
+ content: msg.content,
409
+ }];
410
+ });
411
+ }
412
+
413
+ private convertTools(tools: ToolDefinition[]): OpenRouterTool[] {
414
+ return tools.map(tool => ({
415
+ type: 'function',
416
+ function: {
417
+ name: tool.name,
418
+ description: tool.description,
419
+ parameters: tool.inputSchema,
420
+ },
421
+ }));
422
+ }
423
+
424
+ private async makeRequest(request: any, options?: ProviderRequestOptions): Promise<OpenRouterResponse> {
425
+ const response = await fetch(`${this.baseURL}/chat/completions`, {
426
+ method: 'POST',
427
+ headers: this.getHeaders(),
428
+ body: JSON.stringify(request),
429
+ signal: options?.signal,
430
+ });
431
+
432
+ if (!response.ok) {
433
+ const errorText = await response.text();
434
+ throw new Error(`OpenRouter error: ${response.status} ${errorText}`);
435
+ }
436
+
437
+ return response.json() as Promise<OpenRouterResponse>;
438
+ }
439
+
440
+ private parseResponse(response: OpenRouterResponse, requestedModel: string): ProviderResponse {
441
+ const choice = response.choices[0];
442
+ const message = choice?.message;
443
+
444
+ // Extract cache tokens - OpenRouter passes through both Anthropic and OpenAI caching
445
+ // Anthropic: cache_creation_input_tokens, cache_read_input_tokens
446
+ // OpenAI: prompt_tokens_details.cached_tokens
447
+ const cacheCreationTokens = response.usage?.cache_creation_input_tokens;
448
+ const cacheReadTokens = response.usage?.cache_read_input_tokens
449
+ ?? response.usage?.prompt_tokens_details?.cached_tokens;
450
+
451
+ return {
452
+ content: this.messageToContent(message),
453
+ stopReason: this.mapFinishReason(choice?.finish_reason),
454
+ stopSequence: undefined,
455
+ usage: {
456
+ inputTokens: response.usage?.prompt_tokens ?? 0,
457
+ outputTokens: response.usage?.completion_tokens ?? 0,
458
+ cacheCreationTokens: cacheCreationTokens ?? undefined,
459
+ cacheReadTokens: cacheReadTokens ?? undefined,
460
+ },
461
+ model: response.model ?? requestedModel,
462
+ raw: response,
463
+ };
464
+ }
465
+
466
+ private parseStreamedResponse(
467
+ message: OpenRouterMessage,
468
+ finishReason: string,
469
+ requestedModel: string,
470
+ streamUsage?: OpenRouterResponse['usage']
471
+ ): ProviderResponse {
472
+ // Extract cache tokens if available from stream usage
473
+ const cacheCreationTokens = streamUsage?.cache_creation_input_tokens;
474
+ const cacheReadTokens = streamUsage?.cache_read_input_tokens
475
+ ?? streamUsage?.prompt_tokens_details?.cached_tokens;
476
+
477
+ return {
478
+ content: this.messageToContent(message),
479
+ stopReason: this.mapFinishReason(finishReason),
480
+ stopSequence: undefined,
481
+ usage: {
482
+ inputTokens: streamUsage?.prompt_tokens ?? 0,
483
+ outputTokens: streamUsage?.completion_tokens ?? 0,
484
+ cacheCreationTokens: cacheCreationTokens ?? undefined,
485
+ cacheReadTokens: cacheReadTokens ?? undefined,
486
+ },
487
+ model: requestedModel,
488
+ raw: { message, finish_reason: finishReason, usage: streamUsage },
489
+ };
490
+ }
491
+
492
+ private messageToContent(message: OpenRouterMessage | undefined): any {
493
+ if (!message) return [];
494
+
495
+ const content: any[] = [];
496
+
497
+ if (message.content) {
498
+ content.push({ type: 'text', text: message.content });
499
+ }
500
+
501
+ if (message.tool_calls) {
502
+ for (const tc of message.tool_calls) {
503
+ content.push({
504
+ type: 'tool_use',
505
+ id: tc.id,
506
+ name: tc.function.name,
507
+ input: JSON.parse(tc.function.arguments || '{}'),
508
+ });
509
+ }
510
+ }
511
+
512
+ return content;
513
+ }
514
+
515
+ private mapFinishReason(reason: string | undefined): string {
516
+ switch (reason) {
517
+ case 'stop':
518
+ return 'end_turn';
519
+ case 'length':
520
+ return 'max_tokens';
521
+ case 'tool_calls':
522
+ return 'tool_use';
523
+ case 'content_filter':
524
+ return 'refusal';
525
+ default:
526
+ return 'end_turn';
527
+ }
528
+ }
529
+
530
+ private handleError(error: unknown): MembraneError {
531
+ if (error instanceof Error) {
532
+ const message = error.message;
533
+
534
+ if (message.includes('429') || message.includes('rate')) {
535
+ return rateLimitError(message, undefined, error);
536
+ }
537
+
538
+ if (message.includes('401') || message.includes('auth')) {
539
+ return authError(message, error);
540
+ }
541
+
542
+ if (message.includes('context') || message.includes('too long')) {
543
+ return contextLengthError(message, error);
544
+ }
545
+
546
+ if (message.includes('500') || message.includes('502') || message.includes('503')) {
547
+ return serverError(message, undefined, error);
548
+ }
549
+
550
+ if (error.name === 'AbortError') {
551
+ return abortError();
552
+ }
553
+
554
+ if (message.includes('network') || message.includes('fetch')) {
555
+ return networkError(message, error);
556
+ }
557
+ }
558
+
559
+ return new MembraneError({
560
+ type: 'unknown',
561
+ message: error instanceof Error ? error.message : String(error),
562
+ retryable: false,
563
+ rawError: error,
564
+ });
565
+ }
566
+ }
567
+
568
+ // ============================================================================
569
+ // Content Conversion Utilities
570
+ // ============================================================================
571
+
572
+ /**
573
+ * Convert normalized content blocks to OpenRouter format
574
+ */
575
+ export function toOpenRouterMessages(
576
+ messages: { role: string; content: ContentBlock[] }[]
577
+ ): OpenRouterMessage[] {
578
+ const result: OpenRouterMessage[] = [];
579
+
580
+ for (const msg of messages) {
581
+ const textParts: string[] = [];
582
+ const toolCalls: OpenRouterToolCall[] = [];
583
+ const toolResults: { id: string; content: string }[] = [];
584
+
585
+ for (const block of msg.content) {
586
+ if (block.type === 'text') {
587
+ textParts.push(block.text);
588
+ } else if (block.type === 'tool_use') {
589
+ toolCalls.push({
590
+ id: block.id,
591
+ type: 'function',
592
+ function: {
593
+ name: block.name,
594
+ arguments: JSON.stringify(block.input),
595
+ },
596
+ });
597
+ } else if (block.type === 'tool_result') {
598
+ toolResults.push({
599
+ id: block.toolUseId,
600
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
601
+ });
602
+ }
603
+ }
604
+
605
+ // Add main message
606
+ if (textParts.length > 0 || toolCalls.length > 0) {
607
+ const message: OpenRouterMessage = {
608
+ role: msg.role as 'user' | 'assistant',
609
+ content: textParts.join('\n') || null,
610
+ };
611
+ if (toolCalls.length > 0) {
612
+ message.tool_calls = toolCalls;
613
+ }
614
+ result.push(message);
615
+ }
616
+
617
+ // Add tool results as separate messages
618
+ for (const tr of toolResults) {
619
+ result.push({
620
+ role: 'tool',
621
+ tool_call_id: tr.id,
622
+ content: tr.content,
623
+ });
624
+ }
625
+ }
626
+
627
+ return result;
628
+ }
629
+
630
+ /**
631
+ * Convert OpenRouter response to normalized content blocks
632
+ */
633
+ export function fromOpenRouterMessage(message: OpenRouterMessage): ContentBlock[] {
634
+ const result: ContentBlock[] = [];
635
+
636
+ if (message.content) {
637
+ if (typeof message.content === 'string') {
638
+ result.push({ type: 'text', text: message.content });
639
+ } else if (Array.isArray(message.content)) {
640
+ // Content blocks array - extract text (cache_control is for requests only)
641
+ for (const block of message.content) {
642
+ if (block.type === 'text') {
643
+ result.push({ type: 'text', text: block.text });
644
+ }
645
+ }
646
+ }
647
+ }
648
+
649
+ if (message.tool_calls) {
650
+ for (const tc of message.tool_calls) {
651
+ result.push({
652
+ type: 'tool_use',
653
+ id: tc.id,
654
+ name: tc.function.name,
655
+ input: JSON.parse(tc.function.arguments || '{}'),
656
+ });
657
+ }
658
+ }
659
+
660
+ return result;
661
+ }
662
+