lynkr 3.2.1 → 4.0.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.
@@ -0,0 +1,393 @@
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, model, temperature, max_tokens, top_p, stream, tools, tool_choice } = openaiRequest;
19
+
20
+ // Extract system message if present
21
+ let system = null;
22
+ const anthropicMessages = [];
23
+
24
+ for (const msg of messages) {
25
+ if (msg.role === "system") {
26
+ // Anthropic uses a separate system field
27
+ system = msg.content;
28
+ } else if (msg.role === "user" || msg.role === "assistant") {
29
+ // Convert content format
30
+ let content;
31
+ if (typeof msg.content === "string") {
32
+ content = msg.content;
33
+ } else if (Array.isArray(msg.content)) {
34
+ // OpenAI content parts format
35
+ content = msg.content.map(part => {
36
+ if (part.type === "text") {
37
+ return { type: "text", text: part.text };
38
+ } else if (part.type === "image_url") {
39
+ return {
40
+ type: "image",
41
+ source: {
42
+ type: "url",
43
+ url: part.image_url.url
44
+ }
45
+ };
46
+ }
47
+ return part;
48
+ });
49
+ }
50
+
51
+ // Handle tool calls in assistant messages (OpenAI format)
52
+ if (msg.role === "assistant" && msg.tool_calls) {
53
+ // Convert OpenAI tool_calls to Anthropic tool_use blocks
54
+ const contentBlocks = [];
55
+
56
+ // Add text content if present
57
+ if (msg.content) {
58
+ contentBlocks.push({ type: "text", text: msg.content });
59
+ }
60
+
61
+ // Add tool use blocks
62
+ for (const toolCall of msg.tool_calls) {
63
+ contentBlocks.push({
64
+ type: "tool_use",
65
+ id: toolCall.id,
66
+ name: toolCall.function.name,
67
+ input: JSON.parse(toolCall.function.arguments)
68
+ });
69
+ }
70
+
71
+ anthropicMessages.push({
72
+ role: "assistant",
73
+ content: contentBlocks
74
+ });
75
+ } else {
76
+ anthropicMessages.push({
77
+ role: msg.role,
78
+ content
79
+ });
80
+ }
81
+ } else if (msg.role === "tool") {
82
+ // OpenAI tool response → Anthropic tool_result
83
+ const previousMsg = anthropicMessages[anthropicMessages.length - 1];
84
+
85
+ // Tool results must follow assistant message with tool_use
86
+ // Add as separate user message with tool_result
87
+ anthropicMessages.push({
88
+ role: "user",
89
+ content: [
90
+ {
91
+ type: "tool_result",
92
+ tool_use_id: msg.tool_call_id,
93
+ content: msg.content
94
+ }
95
+ ]
96
+ });
97
+ }
98
+ }
99
+
100
+ // Convert tools format (OpenAI → Anthropic)
101
+ let anthropicTools = null;
102
+ if (tools && tools.length > 0) {
103
+ anthropicTools = tools.map(tool => ({
104
+ name: tool.function.name,
105
+ description: tool.function.description || "",
106
+ input_schema: tool.function.parameters || {
107
+ type: "object",
108
+ properties: {},
109
+ required: []
110
+ }
111
+ }));
112
+ }
113
+
114
+ // Build Anthropic request
115
+ const anthropicRequest = {
116
+ model: model || "claude-3-5-sonnet-20241022",
117
+ messages: anthropicMessages,
118
+ max_tokens: max_tokens || 4096,
119
+ stream: stream || false
120
+ };
121
+
122
+ if (system) {
123
+ anthropicRequest.system = system;
124
+ }
125
+
126
+ if (temperature !== undefined) {
127
+ anthropicRequest.temperature = temperature;
128
+ }
129
+
130
+ if (top_p !== undefined) {
131
+ anthropicRequest.top_p = top_p;
132
+ }
133
+
134
+ if (anthropicTools) {
135
+ anthropicRequest.tools = anthropicTools;
136
+ }
137
+
138
+ // Handle tool_choice
139
+ if (tool_choice) {
140
+ if (tool_choice === "auto") {
141
+ anthropicRequest.tool_choice = { type: "auto" };
142
+ } else if (tool_choice === "none") {
143
+ anthropicRequest.tool_choice = { type: "none" };
144
+ } else if (typeof tool_choice === "object" && tool_choice.function) {
145
+ anthropicRequest.tool_choice = {
146
+ type: "tool",
147
+ name: tool_choice.function.name
148
+ };
149
+ }
150
+ }
151
+
152
+ logger.debug({
153
+ openaiMessageCount: messages.length,
154
+ anthropicMessageCount: anthropicMessages.length,
155
+ hasSystem: !!system,
156
+ hasTools: !!anthropicTools,
157
+ toolCount: anthropicTools?.length || 0
158
+ }, "Converted OpenAI request to Anthropic format");
159
+
160
+ return anthropicRequest;
161
+ }
162
+
163
+ /**
164
+ * Convert Anthropic messages response to OpenAI chat completion format
165
+ * @param {Object} anthropicResponse - Anthropic format response
166
+ * @param {string} model - Model name to include in response
167
+ * @returns {Object} OpenAI format response
168
+ */
169
+ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-20241022") {
170
+ const { id, content, stop_reason, usage } = anthropicResponse;
171
+
172
+ // Convert content blocks to OpenAI format
173
+ let messageContent = "";
174
+ const toolCalls = [];
175
+
176
+ for (const block of content) {
177
+ if (block.type === "text") {
178
+ messageContent += block.text;
179
+ } else if (block.type === "tool_use") {
180
+ toolCalls.push({
181
+ id: block.id,
182
+ type: "function",
183
+ function: {
184
+ name: block.name,
185
+ arguments: JSON.stringify(block.input)
186
+ }
187
+ });
188
+ }
189
+ }
190
+
191
+ // Build OpenAI response
192
+ const openaiResponse = {
193
+ id: id || `chatcmpl-${Date.now()}`,
194
+ object: "chat.completion",
195
+ created: Math.floor(Date.now() / 1000),
196
+ model: model,
197
+ choices: [
198
+ {
199
+ index: 0,
200
+ message: {
201
+ role: "assistant",
202
+ content: messageContent || null
203
+ },
204
+ finish_reason: mapStopReason(stop_reason)
205
+ }
206
+ ],
207
+ usage: {
208
+ prompt_tokens: usage?.input_tokens || 0,
209
+ completion_tokens: usage?.output_tokens || 0,
210
+ total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0)
211
+ }
212
+ };
213
+
214
+ // Add tool_calls if present
215
+ if (toolCalls.length > 0) {
216
+ openaiResponse.choices[0].message.tool_calls = toolCalls;
217
+ openaiResponse.choices[0].finish_reason = "tool_calls";
218
+ }
219
+
220
+ logger.debug({
221
+ anthropicStopReason: stop_reason,
222
+ openaiFinishReason: openaiResponse.choices[0].finish_reason,
223
+ hasToolCalls: toolCalls.length > 0,
224
+ messageLength: messageContent.length
225
+ }, "Converted Anthropic response to OpenAI format");
226
+
227
+ return openaiResponse;
228
+ }
229
+
230
+ /**
231
+ * Convert Anthropic streaming chunk to OpenAI streaming format
232
+ * @param {Object} chunk - Anthropic SSE event
233
+ * @param {string} model - Model name
234
+ * @returns {string} OpenAI format SSE line (data: {...})
235
+ */
236
+ function convertAnthropicStreamChunkToOpenAI(chunk, model = "claude-3-5-sonnet-20241022") {
237
+ const eventType = chunk.type;
238
+
239
+ if (eventType === "message_start") {
240
+ // Initial message metadata
241
+ return {
242
+ id: chunk.message?.id || `chatcmpl-${Date.now()}`,
243
+ object: "chat.completion.chunk",
244
+ created: Math.floor(Date.now() / 1000),
245
+ model: model,
246
+ choices: [
247
+ {
248
+ index: 0,
249
+ delta: { role: "assistant", content: "" },
250
+ finish_reason: null
251
+ }
252
+ ]
253
+ };
254
+ } else if (eventType === "content_block_start") {
255
+ // Start of content block (text or tool_use)
256
+ const contentBlock = chunk.content_block;
257
+
258
+ if (contentBlock?.type === "tool_use") {
259
+ return {
260
+ id: `chatcmpl-${Date.now()}`,
261
+ object: "chat.completion.chunk",
262
+ created: Math.floor(Date.now() / 1000),
263
+ model: model,
264
+ choices: [
265
+ {
266
+ index: 0,
267
+ delta: {
268
+ tool_calls: [
269
+ {
270
+ index: chunk.index,
271
+ id: contentBlock.id,
272
+ type: "function",
273
+ function: {
274
+ name: contentBlock.name,
275
+ arguments: ""
276
+ }
277
+ }
278
+ ]
279
+ },
280
+ finish_reason: null
281
+ }
282
+ ]
283
+ };
284
+ }
285
+ } else if (eventType === "content_block_delta") {
286
+ // Incremental content
287
+ const delta = chunk.delta;
288
+
289
+ if (delta?.type === "text_delta") {
290
+ return {
291
+ id: `chatcmpl-${Date.now()}`,
292
+ object: "chat.completion.chunk",
293
+ created: Math.floor(Date.now() / 1000),
294
+ model: model,
295
+ choices: [
296
+ {
297
+ index: 0,
298
+ delta: { content: delta.text },
299
+ finish_reason: null
300
+ }
301
+ ]
302
+ };
303
+ } else if (delta?.type === "input_json_delta") {
304
+ // Tool call arguments streaming
305
+ return {
306
+ id: `chatcmpl-${Date.now()}`,
307
+ object: "chat.completion.chunk",
308
+ created: Math.floor(Date.now() / 1000),
309
+ model: model,
310
+ choices: [
311
+ {
312
+ index: 0,
313
+ delta: {
314
+ tool_calls: [
315
+ {
316
+ index: chunk.index,
317
+ function: {
318
+ arguments: delta.partial_json
319
+ }
320
+ }
321
+ ]
322
+ },
323
+ finish_reason: null
324
+ }
325
+ ]
326
+ };
327
+ }
328
+ } else if (eventType === "message_delta") {
329
+ // Final message metadata (stop reason, usage)
330
+ const stopReason = chunk.delta?.stop_reason;
331
+ const usage = chunk.usage;
332
+
333
+ return {
334
+ id: `chatcmpl-${Date.now()}`,
335
+ object: "chat.completion.chunk",
336
+ created: Math.floor(Date.now() / 1000),
337
+ model: model,
338
+ choices: [
339
+ {
340
+ index: 0,
341
+ delta: {},
342
+ finish_reason: mapStopReason(stopReason)
343
+ }
344
+ ],
345
+ usage: usage ? {
346
+ prompt_tokens: 0, // Not available in streaming
347
+ completion_tokens: usage.output_tokens || 0,
348
+ total_tokens: usage.output_tokens || 0
349
+ } : undefined
350
+ };
351
+ } else if (eventType === "message_stop") {
352
+ // End of stream
353
+ return {
354
+ id: `chatcmpl-${Date.now()}`,
355
+ object: "chat.completion.chunk",
356
+ created: Math.floor(Date.now() / 1000),
357
+ model: model,
358
+ choices: [
359
+ {
360
+ index: 0,
361
+ delta: {},
362
+ finish_reason: "stop"
363
+ }
364
+ ]
365
+ };
366
+ }
367
+
368
+ // Unknown event type, return empty chunk
369
+ return null;
370
+ }
371
+
372
+ /**
373
+ * Map Anthropic stop_reason to OpenAI finish_reason
374
+ * @param {string} stopReason - Anthropic stop reason
375
+ * @returns {string} OpenAI finish reason
376
+ */
377
+ function mapStopReason(stopReason) {
378
+ const mapping = {
379
+ "end_turn": "stop",
380
+ "max_tokens": "length",
381
+ "stop_sequence": "stop",
382
+ "tool_use": "tool_calls"
383
+ };
384
+
385
+ return mapping[stopReason] || "stop";
386
+ }
387
+
388
+ module.exports = {
389
+ convertOpenAIToAnthropic,
390
+ convertAnthropicToOpenAI,
391
+ convertAnthropicStreamChunkToOpenAI,
392
+ mapStopReason
393
+ };
@@ -78,6 +78,18 @@ function determineProvider(payload) {
78
78
  "Routing to llama.cpp (moderate tools)"
79
79
  );
80
80
  return "llamacpp";
81
+ } else if (config.lmstudio?.endpoint) {
82
+ logger.debug(
83
+ { toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "lmstudio" },
84
+ "Routing to LM Studio (moderate tools)"
85
+ );
86
+ return "lmstudio";
87
+ } else if (config.bedrock?.apiKey) {
88
+ logger.debug(
89
+ { toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "bedrock" },
90
+ "Routing to AWS Bedrock (moderate tools)"
91
+ );
92
+ return "bedrock";
81
93
  }
82
94
  }
83
95
 
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
62
62
  return path.resolve(normalised);
63
63
  }
64
64
 
65
- const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "llamacpp"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
  const modelProvider = SUPPORTED_MODEL_PROVIDERS.has(rawModelProvider)
68
68
  ? rawModelProvider
@@ -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,18 @@ 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`;
108
+
109
+ // LM Studio configuration
110
+ const lmstudioEndpoint = process.env.LMSTUDIO_ENDPOINT?.trim() || "http://localhost:1234";
111
+ const lmstudioModel = process.env.LMSTUDIO_MODEL?.trim() || "default";
112
+ const lmstudioTimeout = Number.parseInt(process.env.LMSTUDIO_TIMEOUT_MS ?? "120000", 10);
113
+ const lmstudioApiKey = process.env.LMSTUDIO_API_KEY?.trim() || null;
114
+
115
+ // AWS Bedrock configuration
116
+ const bedrockRegion = process.env.AWS_BEDROCK_REGION?.trim() || process.env.AWS_REGION?.trim() || "us-east-1";
117
+ const bedrockApiKey = process.env.AWS_BEDROCK_API_KEY?.trim() || null; // Bearer token
118
+ const bedrockModelId = process.env.AWS_BEDROCK_MODEL_ID?.trim() || "anthropic.claude-3-5-sonnet-20241022-v2:0";
104
119
 
105
120
  // Hybrid routing configuration
106
121
  const preferOllama = process.env.PREFER_OLLAMA === "true";
@@ -201,6 +216,22 @@ if (modelProvider === "llamacpp") {
201
216
  }
202
217
  }
203
218
 
219
+ if (modelProvider === "lmstudio") {
220
+ try {
221
+ new URL(lmstudioEndpoint);
222
+ } catch (err) {
223
+ throw new Error("LMSTUDIO_ENDPOINT must be a valid URL (default: http://localhost:1234)");
224
+ }
225
+ }
226
+
227
+ // Validate Bedrock credentials when it's the primary provider
228
+ if (modelProvider === "bedrock" && !bedrockApiKey) {
229
+ throw new Error(
230
+ "AWS Bedrock requires AWS_BEDROCK_API_KEY (Bearer token). " +
231
+ "Generate from AWS Console → Bedrock → API Keys, then set AWS_BEDROCK_API_KEY in your .env file."
232
+ );
233
+ }
234
+
204
235
  // Validate hybrid routing configuration
205
236
  if (preferOllama) {
206
237
  if (!ollamaEndpoint) {
@@ -211,8 +242,11 @@ if (preferOllama) {
211
242
  `FALLBACK_PROVIDER must be one of: ${Array.from(SUPPORTED_MODEL_PROVIDERS).join(", ")}`
212
243
  );
213
244
  }
214
- if (fallbackEnabled && fallbackProvider === "ollama") {
215
- throw new Error("FALLBACK_PROVIDER cannot be 'ollama' (circular fallback)");
245
+
246
+ // Prevent local providers from being used as fallback (they can fail just like Ollama)
247
+ const localProviders = ["ollama", "llamacpp", "lmstudio"];
248
+ if (fallbackEnabled && localProviders.includes(fallbackProvider)) {
249
+ throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, openai, bedrock`);
216
250
  }
217
251
 
218
252
  // Ensure fallback provider is properly configured (only if fallback is enabled)
@@ -226,6 +260,9 @@ if (preferOllama) {
226
260
  if (fallbackProvider === "azure-openai" && (!azureOpenAIEndpoint || !azureOpenAIApiKey)) {
227
261
  throw new Error("FALLBACK_PROVIDER is set to 'azure-openai' but AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY are not configured. Please set these environment variables or choose a different fallback provider.");
228
262
  }
263
+ if (fallbackProvider === "bedrock" && !bedrockApiKey) {
264
+ throw new Error("FALLBACK_PROVIDER is set to 'bedrock' but AWS_BEDROCK_API_KEY is not configured. Please set this environment variable or choose a different fallback provider.");
265
+ }
229
266
  }
230
267
  }
231
268
 
@@ -400,10 +437,13 @@ const config = {
400
437
  endpoint: ollamaEndpoint,
401
438
  model: ollamaModel,
402
439
  timeout: Number.isNaN(ollamaTimeout) ? 120000 : ollamaTimeout,
440
+ embeddingsEndpoint: ollamaEmbeddingsEndpoint,
441
+ embeddingsModel: ollamaEmbeddingsModel,
403
442
  },
404
443
  openrouter: {
405
444
  apiKey: openRouterApiKey,
406
445
  model: openRouterModel,
446
+ embeddingsModel: openRouterEmbeddingsModel,
407
447
  endpoint: openRouterEndpoint,
408
448
  },
409
449
  azureOpenAI: {
@@ -423,6 +463,18 @@ const config = {
423
463
  model: llamacppModel,
424
464
  timeout: Number.isNaN(llamacppTimeout) ? 120000 : llamacppTimeout,
425
465
  apiKey: llamacppApiKey,
466
+ embeddingsEndpoint: llamacppEmbeddingsEndpoint,
467
+ },
468
+ lmstudio: {
469
+ endpoint: lmstudioEndpoint,
470
+ model: lmstudioModel,
471
+ timeout: Number.isNaN(lmstudioTimeout) ? 120000 : lmstudioTimeout,
472
+ apiKey: lmstudioApiKey,
473
+ },
474
+ bedrock: {
475
+ region: bedrockRegion,
476
+ apiKey: bedrockApiKey,
477
+ modelId: bedrockModelId,
426
478
  },
427
479
  modelProvider: {
428
480
  type: modelProvider,
@@ -1994,7 +1994,14 @@ async function runAgentLoop({
1994
1994
  // Use actualProvider from invokeModel for hybrid routing support
1995
1995
  const actualProvider = databricksResponse.actualProvider || providerType;
1996
1996
 
1997
- if (actualProvider === "azure-anthropic") {
1997
+ if (actualProvider === "bedrock") {
1998
+ // Bedrock with Claude models returns native Anthropic format
1999
+ // Other models are already converted by bedrock-utils
2000
+ anthropicPayload = databricksResponse.json;
2001
+ if (Array.isArray(anthropicPayload?.content)) {
2002
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2003
+ }
2004
+ } else if (actualProvider === "azure-anthropic") {
1998
2005
  anthropicPayload = databricksResponse.json;
1999
2006
  if (Array.isArray(anthropicPayload?.content)) {
2000
2007
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
@@ -16,7 +16,7 @@ const TECHNICAL_KEYWORDS = /code|function|class|file|module|import|export|async|
16
16
  const EXPLANATION_PATTERN = /explain|describe|summarize|what does|how does|tell me about|give me an overview|clarify|elaborate/i;
17
17
  const WEB_PATTERN = /search|lookup|find info|google|documentation|docs|website|url|link|online|internet|browse/i;
18
18
  const READ_PATTERN = /read|show|display|view|cat|check|inspect|look at|see|examine|review|print|output/i;
19
- const WRITE_PATTERN = /write|create|add|update|modify|change|fix|delete|remove|insert|append|replace|save/i;
19
+ const WRITE_PATTERN = /write|create|add|update|modify|change|fix|delete|remove|insert|append|replace|save|put|make|generate|produce/i;
20
20
  const EDIT_PATTERN = /edit|refactor|rename|move|reorganize|restructure|rewrite/i;
21
21
  const EXECUTION_PATTERN = /run|execute|test|compile|build|deploy|start|install|launch|boot|fire up|npm|git|python|node|docker|bash|sh|cmd/i;
22
22
  const COMPLEX_PATTERN = /implement|build|create|develop|design|architect|plan|strategy|approach|help with|work on|improve|optimize|enhance|refactor|migrate/i;