lynkr 3.3.1 → 4.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/src/api/router.js CHANGED
@@ -3,6 +3,7 @@ const { processMessage } = require("../orchestrator");
3
3
  const { getSession } = require("../sessions");
4
4
  const metrics = require("../metrics");
5
5
  const { createRateLimiter } = require("./middleware/rate-limiter");
6
+ const openaiRouter = require("./openai-router");
6
7
 
7
8
  const router = express.Router();
8
9
 
@@ -383,4 +384,7 @@ router.get("/api/tokens/stats", (req, res) => {
383
384
  }
384
385
  });
385
386
 
387
+ // Mount OpenAI-compatible endpoints for Cursor IDE support
388
+ router.use("/v1", openaiRouter);
389
+
386
390
  module.exports = router;
@@ -0,0 +1,427 @@
1
+ /**
2
+ * OpenAI ↔ Anthropic Format Conversion Utilities
3
+ *
4
+ * Converts between OpenAI's /v1/chat/completions format and Anthropic's /v1/messages format.
5
+ * Used for Cursor IDE compatibility.
6
+ *
7
+ * @module clients/openai-format
8
+ */
9
+
10
+ const logger = require("../logger");
11
+
12
+ /**
13
+ * Convert OpenAI chat completion request to Anthropic messages format
14
+ * @param {Object} openaiRequest - OpenAI format request
15
+ * @returns {Object} Anthropic format request
16
+ */
17
+ function convertOpenAIToAnthropic(openaiRequest) {
18
+ const { messages, input, model, temperature, max_tokens, top_p, stream, tools, tool_choice } = openaiRequest;
19
+
20
+ // Cursor's inline edit uses "input" instead of "messages"
21
+ const messageArray = messages || input;
22
+
23
+ // Validate messages/input field
24
+ if (!messageArray) {
25
+ logger.error({
26
+ openaiRequest: JSON.stringify(openaiRequest),
27
+ hasMessages: !!messages,
28
+ hasInput: !!input,
29
+ messagesType: typeof messages,
30
+ inputType: typeof input
31
+ }, "convertOpenAIToAnthropic: neither messages nor input field present");
32
+ throw new Error("OpenAI request missing 'messages' or 'input' field");
33
+ }
34
+
35
+ if (!Array.isArray(messageArray)) {
36
+ logger.error({
37
+ messageArray: JSON.stringify(messageArray),
38
+ messageArrayType: typeof messageArray,
39
+ isArray: Array.isArray(messageArray)
40
+ }, "convertOpenAIToAnthropic: messages/input is not an array");
41
+ throw new Error(`OpenAI request 'messages'/'input' must be an array, got ${typeof messageArray}`);
42
+ }
43
+
44
+ // Extract system message if present
45
+ let system = null;
46
+ const anthropicMessages = [];
47
+
48
+ for (const msg of messageArray) {
49
+ if (msg.role === "system") {
50
+ // Anthropic uses a separate system field
51
+ system = msg.content;
52
+ } else if (msg.role === "user" || msg.role === "assistant") {
53
+ // Convert content format
54
+ let content;
55
+ if (typeof msg.content === "string") {
56
+ content = msg.content;
57
+ } else if (Array.isArray(msg.content)) {
58
+ // OpenAI content parts format
59
+ content = msg.content.map(part => {
60
+ if (part.type === "text") {
61
+ return { type: "text", text: part.text };
62
+ } else if (part.type === "image_url") {
63
+ return {
64
+ type: "image",
65
+ source: {
66
+ type: "url",
67
+ url: part.image_url.url
68
+ }
69
+ };
70
+ }
71
+ return part;
72
+ });
73
+ }
74
+
75
+ // Handle tool calls in assistant messages (OpenAI format)
76
+ if (msg.role === "assistant" && msg.tool_calls) {
77
+ // Convert OpenAI tool_calls to Anthropic tool_use blocks
78
+ const contentBlocks = [];
79
+
80
+ // Add text content if present
81
+ if (msg.content) {
82
+ contentBlocks.push({ type: "text", text: msg.content });
83
+ }
84
+
85
+ // Add tool use blocks
86
+ for (const toolCall of msg.tool_calls) {
87
+ contentBlocks.push({
88
+ type: "tool_use",
89
+ id: toolCall.id,
90
+ name: toolCall.function.name,
91
+ input: JSON.parse(toolCall.function.arguments)
92
+ });
93
+ }
94
+
95
+ anthropicMessages.push({
96
+ role: "assistant",
97
+ content: contentBlocks
98
+ });
99
+ } else {
100
+ anthropicMessages.push({
101
+ role: msg.role,
102
+ content
103
+ });
104
+ }
105
+ } else if (msg.role === "tool") {
106
+ // OpenAI tool response → Anthropic tool_result
107
+ const previousMsg = anthropicMessages[anthropicMessages.length - 1];
108
+
109
+ // Tool results must follow assistant message with tool_use
110
+ // Add as separate user message with tool_result
111
+ anthropicMessages.push({
112
+ role: "user",
113
+ content: [
114
+ {
115
+ type: "tool_result",
116
+ tool_use_id: msg.tool_call_id,
117
+ content: msg.content
118
+ }
119
+ ]
120
+ });
121
+ }
122
+ }
123
+
124
+ // Convert tools format (OpenAI → Anthropic)
125
+ let anthropicTools = null;
126
+ if (tools && tools.length > 0) {
127
+ anthropicTools = tools.map(tool => ({
128
+ name: tool.function.name,
129
+ description: tool.function.description || "",
130
+ input_schema: tool.function.parameters || {
131
+ type: "object",
132
+ properties: {},
133
+ required: []
134
+ }
135
+ }));
136
+ }
137
+
138
+ // Build Anthropic request
139
+ const anthropicRequest = {
140
+ model: model || "claude-3-5-sonnet-20241022",
141
+ messages: anthropicMessages,
142
+ max_tokens: max_tokens || 4096,
143
+ stream: stream || false
144
+ };
145
+
146
+ if (system) {
147
+ anthropicRequest.system = system;
148
+ }
149
+
150
+ if (temperature !== undefined) {
151
+ anthropicRequest.temperature = temperature;
152
+ }
153
+
154
+ if (top_p !== undefined) {
155
+ anthropicRequest.top_p = top_p;
156
+ }
157
+
158
+ if (anthropicTools) {
159
+ anthropicRequest.tools = anthropicTools;
160
+ }
161
+
162
+ // Handle tool_choice
163
+ if (tool_choice) {
164
+ if (tool_choice === "auto") {
165
+ anthropicRequest.tool_choice = { type: "auto" };
166
+ } else if (tool_choice === "none") {
167
+ anthropicRequest.tool_choice = { type: "none" };
168
+ } else if (typeof tool_choice === "object" && tool_choice.function) {
169
+ anthropicRequest.tool_choice = {
170
+ type: "tool",
171
+ name: tool_choice.function.name
172
+ };
173
+ }
174
+ }
175
+
176
+ logger.debug({
177
+ openaiMessageCount: messageArray.length,
178
+ anthropicMessageCount: anthropicMessages.length,
179
+ hasSystem: !!system,
180
+ hasTools: !!anthropicTools,
181
+ toolCount: anthropicTools?.length || 0
182
+ }, "Converted OpenAI request to Anthropic format");
183
+
184
+ return anthropicRequest;
185
+ }
186
+
187
+ /**
188
+ * Convert Anthropic messages response to OpenAI chat completion format
189
+ * @param {Object} anthropicResponse - Anthropic format response
190
+ * @param {string} model - Model name to include in response
191
+ * @returns {Object} OpenAI format response
192
+ */
193
+ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-20241022") {
194
+ // Validate input
195
+ if (!anthropicResponse) {
196
+ throw new Error("convertAnthropicToOpenAI: anthropicResponse is undefined or null");
197
+ }
198
+
199
+ const { id, content, stop_reason, usage } = anthropicResponse;
200
+
201
+ // Validate required fields
202
+ if (!content || !Array.isArray(content)) {
203
+ throw new Error(`convertAnthropicToOpenAI: invalid content field (got ${typeof content})`);
204
+ }
205
+
206
+ // Convert content blocks to OpenAI format
207
+ let messageContent = "";
208
+ const toolCalls = [];
209
+
210
+ for (const block of content) {
211
+ if (block.type === "text") {
212
+ messageContent += block.text;
213
+ } else if (block.type === "tool_use") {
214
+ toolCalls.push({
215
+ id: block.id,
216
+ type: "function",
217
+ function: {
218
+ name: block.name,
219
+ arguments: JSON.stringify(block.input)
220
+ }
221
+ });
222
+ }
223
+ }
224
+
225
+ // Build OpenAI response
226
+ const openaiResponse = {
227
+ id: id || `chatcmpl-${Date.now()}`,
228
+ object: "chat.completion",
229
+ created: Math.floor(Date.now() / 1000),
230
+ model: model,
231
+ choices: [
232
+ {
233
+ index: 0,
234
+ message: {
235
+ role: "assistant",
236
+ content: messageContent || null
237
+ },
238
+ finish_reason: mapStopReason(stop_reason)
239
+ }
240
+ ],
241
+ usage: {
242
+ prompt_tokens: usage?.input_tokens || 0,
243
+ completion_tokens: usage?.output_tokens || 0,
244
+ total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0)
245
+ }
246
+ };
247
+
248
+ // Add tool_calls if present
249
+ if (toolCalls.length > 0) {
250
+ openaiResponse.choices[0].message.tool_calls = toolCalls;
251
+ openaiResponse.choices[0].finish_reason = "tool_calls";
252
+ }
253
+
254
+ logger.debug({
255
+ anthropicStopReason: stop_reason,
256
+ openaiFinishReason: openaiResponse.choices[0].finish_reason,
257
+ hasToolCalls: toolCalls.length > 0,
258
+ messageLength: messageContent.length
259
+ }, "Converted Anthropic response to OpenAI format");
260
+
261
+ return openaiResponse;
262
+ }
263
+
264
+ /**
265
+ * Convert Anthropic streaming chunk to OpenAI streaming format
266
+ * @param {Object} chunk - Anthropic SSE event
267
+ * @param {string} model - Model name
268
+ * @returns {string} OpenAI format SSE line (data: {...})
269
+ */
270
+ function convertAnthropicStreamChunkToOpenAI(chunk, model = "claude-3-5-sonnet-20241022") {
271
+ const eventType = chunk.type;
272
+
273
+ if (eventType === "message_start") {
274
+ // Initial message metadata
275
+ return {
276
+ id: chunk.message?.id || `chatcmpl-${Date.now()}`,
277
+ object: "chat.completion.chunk",
278
+ created: Math.floor(Date.now() / 1000),
279
+ model: model,
280
+ choices: [
281
+ {
282
+ index: 0,
283
+ delta: { role: "assistant", content: "" },
284
+ finish_reason: null
285
+ }
286
+ ]
287
+ };
288
+ } else if (eventType === "content_block_start") {
289
+ // Start of content block (text or tool_use)
290
+ const contentBlock = chunk.content_block;
291
+
292
+ if (contentBlock?.type === "tool_use") {
293
+ return {
294
+ id: `chatcmpl-${Date.now()}`,
295
+ object: "chat.completion.chunk",
296
+ created: Math.floor(Date.now() / 1000),
297
+ model: model,
298
+ choices: [
299
+ {
300
+ index: 0,
301
+ delta: {
302
+ tool_calls: [
303
+ {
304
+ index: chunk.index,
305
+ id: contentBlock.id,
306
+ type: "function",
307
+ function: {
308
+ name: contentBlock.name,
309
+ arguments: ""
310
+ }
311
+ }
312
+ ]
313
+ },
314
+ finish_reason: null
315
+ }
316
+ ]
317
+ };
318
+ }
319
+ } else if (eventType === "content_block_delta") {
320
+ // Incremental content
321
+ const delta = chunk.delta;
322
+
323
+ if (delta?.type === "text_delta") {
324
+ return {
325
+ id: `chatcmpl-${Date.now()}`,
326
+ object: "chat.completion.chunk",
327
+ created: Math.floor(Date.now() / 1000),
328
+ model: model,
329
+ choices: [
330
+ {
331
+ index: 0,
332
+ delta: { content: delta.text },
333
+ finish_reason: null
334
+ }
335
+ ]
336
+ };
337
+ } else if (delta?.type === "input_json_delta") {
338
+ // Tool call arguments streaming
339
+ return {
340
+ id: `chatcmpl-${Date.now()}`,
341
+ object: "chat.completion.chunk",
342
+ created: Math.floor(Date.now() / 1000),
343
+ model: model,
344
+ choices: [
345
+ {
346
+ index: 0,
347
+ delta: {
348
+ tool_calls: [
349
+ {
350
+ index: chunk.index,
351
+ function: {
352
+ arguments: delta.partial_json
353
+ }
354
+ }
355
+ ]
356
+ },
357
+ finish_reason: null
358
+ }
359
+ ]
360
+ };
361
+ }
362
+ } else if (eventType === "message_delta") {
363
+ // Final message metadata (stop reason, usage)
364
+ const stopReason = chunk.delta?.stop_reason;
365
+ const usage = chunk.usage;
366
+
367
+ return {
368
+ id: `chatcmpl-${Date.now()}`,
369
+ object: "chat.completion.chunk",
370
+ created: Math.floor(Date.now() / 1000),
371
+ model: model,
372
+ choices: [
373
+ {
374
+ index: 0,
375
+ delta: {},
376
+ finish_reason: mapStopReason(stopReason)
377
+ }
378
+ ],
379
+ usage: usage ? {
380
+ prompt_tokens: 0, // Not available in streaming
381
+ completion_tokens: usage.output_tokens || 0,
382
+ total_tokens: usage.output_tokens || 0
383
+ } : undefined
384
+ };
385
+ } else if (eventType === "message_stop") {
386
+ // End of stream
387
+ return {
388
+ id: `chatcmpl-${Date.now()}`,
389
+ object: "chat.completion.chunk",
390
+ created: Math.floor(Date.now() / 1000),
391
+ model: model,
392
+ choices: [
393
+ {
394
+ index: 0,
395
+ delta: {},
396
+ finish_reason: "stop"
397
+ }
398
+ ]
399
+ };
400
+ }
401
+
402
+ // Unknown event type, return empty chunk
403
+ return null;
404
+ }
405
+
406
+ /**
407
+ * Map Anthropic stop_reason to OpenAI finish_reason
408
+ * @param {string} stopReason - Anthropic stop reason
409
+ * @returns {string} OpenAI finish reason
410
+ */
411
+ function mapStopReason(stopReason) {
412
+ const mapping = {
413
+ "end_turn": "stop",
414
+ "max_tokens": "length",
415
+ "stop_sequence": "stop",
416
+ "tool_use": "tool_calls"
417
+ };
418
+
419
+ return mapping[stopReason] || "stop";
420
+ }
421
+
422
+ module.exports = {
423
+ convertOpenAIToAnthropic,
424
+ convertAnthropicToOpenAI,
425
+ convertAnthropicStreamChunkToOpenAI,
426
+ mapStopReason
427
+ };
@@ -78,10 +78,13 @@ const azureAnthropicVersion = process.env.AZURE_ANTHROPIC_VERSION ?? "2023-06-01
78
78
  const ollamaEndpoint = process.env.OLLAMA_ENDPOINT ?? "http://localhost:11434";
79
79
  const ollamaModel = process.env.OLLAMA_MODEL ?? "qwen2.5-coder:7b";
80
80
  const ollamaTimeout = Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "120000", 10);
81
+ const ollamaEmbeddingsEndpoint = process.env.OLLAMA_EMBEDDINGS_ENDPOINT ?? `${ollamaEndpoint}/api/embeddings`;
82
+ const ollamaEmbeddingsModel = process.env.OLLAMA_EMBEDDINGS_MODEL ?? "nomic-embed-text";
81
83
 
82
84
  // OpenRouter configuration
83
85
  const openRouterApiKey = process.env.OPENROUTER_API_KEY ?? null;
84
86
  const openRouterModel = process.env.OPENROUTER_MODEL ?? "openai/gpt-4o-mini";
87
+ const openRouterEmbeddingsModel = process.env.OPENROUTER_EMBEDDINGS_MODEL ?? "openai/text-embedding-ada-002";
85
88
  const openRouterEndpoint = process.env.OPENROUTER_ENDPOINT ?? "https://openrouter.ai/api/v1/chat/completions";
86
89
 
87
90
  // Azure OpenAI configuration
@@ -101,6 +104,7 @@ const llamacppEndpoint = process.env.LLAMACPP_ENDPOINT?.trim() || "http://localh
101
104
  const llamacppModel = process.env.LLAMACPP_MODEL?.trim() || "default";
102
105
  const llamacppTimeout = Number.parseInt(process.env.LLAMACPP_TIMEOUT_MS ?? "120000", 10);
103
106
  const llamacppApiKey = process.env.LLAMACPP_API_KEY?.trim() || null;
107
+ const llamacppEmbeddingsEndpoint = process.env.LLAMACPP_EMBEDDINGS_ENDPOINT?.trim() || `${llamacppEndpoint}/embeddings`;
104
108
 
105
109
  // LM Studio configuration
106
110
  const lmstudioEndpoint = process.env.LMSTUDIO_ENDPOINT?.trim() || "http://localhost:1234";
@@ -433,10 +437,13 @@ const config = {
433
437
  endpoint: ollamaEndpoint,
434
438
  model: ollamaModel,
435
439
  timeout: Number.isNaN(ollamaTimeout) ? 120000 : ollamaTimeout,
440
+ embeddingsEndpoint: ollamaEmbeddingsEndpoint,
441
+ embeddingsModel: ollamaEmbeddingsModel,
436
442
  },
437
443
  openrouter: {
438
444
  apiKey: openRouterApiKey,
439
445
  model: openRouterModel,
446
+ embeddingsModel: openRouterEmbeddingsModel,
440
447
  endpoint: openRouterEndpoint,
441
448
  },
442
449
  azureOpenAI: {
@@ -456,6 +463,7 @@ const config = {
456
463
  model: llamacppModel,
457
464
  timeout: Number.isNaN(llamacppTimeout) ? 120000 : llamacppTimeout,
458
465
  apiKey: llamacppApiKey,
466
+ embeddingsEndpoint: llamacppEmbeddingsEndpoint,
459
467
  },
460
468
  lmstudio: {
461
469
  endpoint: lmstudioEndpoint,