@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.
- package/dist/context/index.d.ts +10 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +9 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/process.d.ts +22 -0
- package/dist/context/process.d.ts.map +1 -0
- package/dist/context/process.js +369 -0
- package/dist/context/process.js.map +1 -0
- package/dist/context/types.d.ts +118 -0
- package/dist/context/types.d.ts.map +1 -0
- package/dist/context/types.js +60 -0
- package/dist/context/types.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/membrane.d.ts +96 -0
- package/dist/membrane.d.ts.map +1 -0
- package/dist/membrane.js +893 -0
- package/dist/membrane.js.map +1 -0
- package/dist/providers/anthropic.d.ts +36 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +265 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +8 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +74 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -0
- package/dist/providers/openai-compatible.js +412 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/providers/openai.d.ts +69 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +455 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/openrouter.d.ts +76 -0
- package/dist/providers/openrouter.d.ts.map +1 -0
- package/dist/providers/openrouter.js +492 -0
- package/dist/providers/openrouter.js.map +1 -0
- package/dist/transforms/chat.d.ts +52 -0
- package/dist/transforms/chat.d.ts.map +1 -0
- package/dist/transforms/chat.js +136 -0
- package/dist/transforms/chat.js.map +1 -0
- package/dist/transforms/index.d.ts +6 -0
- package/dist/transforms/index.d.ts.map +1 -0
- package/dist/transforms/index.js +6 -0
- package/dist/transforms/index.js.map +1 -0
- package/dist/transforms/prefill.d.ts +89 -0
- package/dist/transforms/prefill.d.ts.map +1 -0
- package/dist/transforms/prefill.js +401 -0
- package/dist/transforms/prefill.js.map +1 -0
- package/dist/types/config.d.ts +103 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +21 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/content.d.ts +81 -0
- package/dist/types/content.d.ts.map +1 -0
- package/dist/types/content.js +40 -0
- package/dist/types/content.js.map +1 -0
- package/dist/types/errors.d.ts +42 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +208 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +9 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/message.d.ts +46 -0
- package/dist/types/message.d.ts.map +1 -0
- package/dist/types/message.js +38 -0
- package/dist/types/message.js.map +1 -0
- package/dist/types/provider.d.ts +155 -0
- package/dist/types/provider.d.ts.map +1 -0
- package/dist/types/provider.js +5 -0
- package/dist/types/provider.js.map +1 -0
- package/dist/types/request.d.ts +78 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +5 -0
- package/dist/types/request.js.map +1 -0
- package/dist/types/response.d.ts +131 -0
- package/dist/types/response.d.ts.map +1 -0
- package/dist/types/response.js +7 -0
- package/dist/types/response.js.map +1 -0
- package/dist/types/streaming.d.ts +164 -0
- package/dist/types/streaming.d.ts.map +1 -0
- package/dist/types/streaming.js +5 -0
- package/dist/types/streaming.js.map +1 -0
- package/dist/types/tools.d.ts +71 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/types/tools.js +5 -0
- package/dist/types/tools.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/stream-parser.d.ts +53 -0
- package/dist/utils/stream-parser.d.ts.map +1 -0
- package/dist/utils/stream-parser.js +359 -0
- package/dist/utils/stream-parser.js.map +1 -0
- package/dist/utils/tool-parser.d.ts +130 -0
- package/dist/utils/tool-parser.d.ts.map +1 -0
- package/dist/utils/tool-parser.js +571 -0
- package/dist/utils/tool-parser.js.map +1 -0
- package/package.json +37 -0
- package/src/context/index.ts +24 -0
- package/src/context/process.ts +520 -0
- package/src/context/types.ts +231 -0
- package/src/index.ts +23 -0
- package/src/membrane.ts +1174 -0
- package/src/providers/anthropic.ts +340 -0
- package/src/providers/index.ts +31 -0
- package/src/providers/openai-compatible.ts +570 -0
- package/src/providers/openai.ts +625 -0
- package/src/providers/openrouter.ts +662 -0
- package/src/transforms/chat.ts +212 -0
- package/src/transforms/index.ts +22 -0
- package/src/transforms/prefill.ts +585 -0
- package/src/types/config.ts +172 -0
- package/src/types/content.ts +181 -0
- package/src/types/errors.ts +277 -0
- package/src/types/index.ts +154 -0
- package/src/types/message.ts +89 -0
- package/src/types/provider.ts +249 -0
- package/src/types/request.ts +131 -0
- package/src/types/response.ts +223 -0
- package/src/types/streaming.ts +231 -0
- package/src/types/tools.ts +92 -0
- package/src/utils/index.ts +15 -0
- package/src/utils/stream-parser.ts +440 -0
- 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
|
+
|