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.
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,298 @@
1
+ /**
2
+ * AWS Bedrock Model Format Utilities
3
+ *
4
+ * Handles format conversion between Anthropic format and various Bedrock model families.
5
+ * Supports: Claude, Titan, Llama, Jurassic, Cohere, Mistral
6
+ *
7
+ * @module clients/bedrock-utils
8
+ */
9
+
10
+ const logger = require("../logger");
11
+
12
+ /**
13
+ * Convert Anthropic messages array to a simple text prompt
14
+ * @param {Array} messages - Anthropic messages array
15
+ * @returns {string} Combined prompt text
16
+ */
17
+ function messagesToPrompt(messages) {
18
+ return messages
19
+ .map((msg) => {
20
+ const role = msg.role === "user" ? "Human" : "Assistant";
21
+ let content = "";
22
+
23
+ if (typeof msg.content === "string") {
24
+ content = msg.content;
25
+ } else if (Array.isArray(msg.content)) {
26
+ content = msg.content
27
+ .filter((block) => block.type === "text")
28
+ .map((block) => block.text)
29
+ .join("\n");
30
+ }
31
+
32
+ return `${role}: ${content}`;
33
+ })
34
+ .join("\n\n");
35
+ }
36
+
37
+ /**
38
+ * Detect which model family a Bedrock model ID belongs to
39
+ * @param {string} modelId - AWS Bedrock model ID or inference profile ID
40
+ * Examples: "anthropic.claude-3-5-sonnet-20241022-v2:0", "us.deepseek.r1-v1:0", "qwen.qwen3-235b-v1:0"
41
+ * @returns {string} Model family identifier
42
+ */
43
+ function detectModelFamily(modelId) {
44
+ // Handle inference profiles (e.g., "global.anthropic.claude-..." or "us.deepseek.r1-...")
45
+ if (modelId.includes(".anthropic.claude")) return "claude";
46
+ if (modelId.includes(".amazon.titan")) return "titan";
47
+ if (modelId.includes(".amazon.nova")) return "nova";
48
+ if (modelId.includes(".meta.llama")) return "llama";
49
+ if (modelId.includes(".ai21.jamba")) return "jamba";
50
+ if (modelId.includes(".cohere.command")) return "cohere";
51
+ if (modelId.includes(".mistral")) return "mistral";
52
+ if (modelId.includes(".deepseek")) return "deepseek";
53
+ if (modelId.includes(".qwen")) return "qwen";
54
+ if (modelId.includes(".openai")) return "openai";
55
+ if (modelId.includes(".google.gemma")) return "gemma";
56
+ if (modelId.includes(".minimax")) return "minimax";
57
+ if (modelId.includes(".writer")) return "writer";
58
+ if (modelId.includes(".kimi")) return "kimi";
59
+ if (modelId.includes(".luma")) return "luma";
60
+ if (modelId.includes(".twelvelabs")) return "twelvelabs";
61
+
62
+ // Handle direct model IDs (standard format)
63
+ if (modelId.startsWith("anthropic.claude")) return "claude";
64
+ if (modelId.startsWith("amazon.titan")) return "titan";
65
+ if (modelId.startsWith("amazon.nova")) return "nova";
66
+ if (modelId.startsWith("meta.llama")) return "llama";
67
+ if (modelId.startsWith("ai21.j2")) return "jurassic";
68
+ if (modelId.startsWith("ai21.jamba")) return "jamba";
69
+ if (modelId.startsWith("cohere.command")) return "cohere";
70
+ if (modelId.startsWith("mistral.")) return "mistral";
71
+ if (modelId.startsWith("deepseek.")) return "deepseek";
72
+ if (modelId.startsWith("qwen.")) return "qwen";
73
+ if (modelId.startsWith("openai.")) return "openai";
74
+ if (modelId.startsWith("google.gemma")) return "gemma";
75
+ if (modelId.startsWith("minimax.")) return "minimax";
76
+ if (modelId.startsWith("writer.")) return "writer";
77
+ if (modelId.startsWith("kimi.")) return "kimi";
78
+ if (modelId.startsWith("luma.")) return "luma";
79
+ if (modelId.startsWith("twelvelabs.")) return "twelvelabs";
80
+
81
+ // If we can't detect, assume it works with Converse API (Bedrock's unified API)
82
+ logger.info({ modelId }, "Unknown Bedrock model family - assuming Converse API compatibility");
83
+ return "converse";
84
+ }
85
+
86
+ /**
87
+ * Convert Anthropic format request to Bedrock-specific format
88
+ * @param {Object} body - Request body in Anthropic format
89
+ * @param {string} modelFamily - Model family from detectModelFamily()
90
+ * @returns {Object} Request body in Bedrock model-specific format
91
+ */
92
+ function convertAnthropicToBedrockFormat(body, modelFamily) {
93
+ switch (modelFamily) {
94
+ case "claude":
95
+ // Claude models use native Anthropic Messages API format
96
+ // Only need to add anthropic_version field for Bedrock
97
+ return {
98
+ anthropic_version: "bedrock-2023-05-31",
99
+ max_tokens: body.max_tokens || 4096,
100
+ messages: body.messages,
101
+ system: body.system,
102
+ temperature: body.temperature,
103
+ top_p: body.top_p,
104
+ tools: body.tools,
105
+ };
106
+
107
+ case "titan":
108
+ // Amazon Titan format
109
+ return {
110
+ inputText: messagesToPrompt(body.messages),
111
+ textGenerationConfig: {
112
+ maxTokenCount: body.max_tokens || 4096,
113
+ temperature: body.temperature || 0.7,
114
+ topP: body.top_p || 1.0,
115
+ stopSequences: [],
116
+ },
117
+ };
118
+
119
+ case "llama":
120
+ // Meta Llama format
121
+ const llamaPrompt = messagesToPrompt(body.messages);
122
+ return {
123
+ prompt: llamaPrompt,
124
+ max_gen_len: body.max_tokens || 2048,
125
+ temperature: body.temperature || 0.7,
126
+ top_p: body.top_p || 0.9,
127
+ };
128
+
129
+ case "jurassic":
130
+ // AI21 Jurassic format
131
+ return {
132
+ prompt: messagesToPrompt(body.messages),
133
+ maxTokens: body.max_tokens || 200,
134
+ temperature: body.temperature || 0.7,
135
+ topP: body.top_p || 1,
136
+ stopSequences: [],
137
+ countPenalty: {
138
+ scale: 0,
139
+ },
140
+ presencePenalty: {
141
+ scale: 0,
142
+ },
143
+ frequencyPenalty: {
144
+ scale: 0,
145
+ },
146
+ };
147
+
148
+ case "cohere":
149
+ // Cohere Command format
150
+ return {
151
+ prompt: messagesToPrompt(body.messages),
152
+ max_tokens: body.max_tokens || 400,
153
+ temperature: body.temperature || 0.75,
154
+ p: body.top_p || 1.0,
155
+ k: 0,
156
+ stop_sequences: [],
157
+ return_likelihoods: "NONE",
158
+ };
159
+
160
+ case "mistral":
161
+ // Mistral format (similar to OpenAI)
162
+ return {
163
+ prompt: messagesToPrompt(body.messages),
164
+ max_tokens: body.max_tokens || 2048,
165
+ temperature: body.temperature || 0.7,
166
+ top_p: body.top_p || 1.0,
167
+ stop: [],
168
+ };
169
+
170
+ default:
171
+ throw new Error(`Unsupported model family: ${modelFamily}`);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Convert Bedrock response to Anthropic format
177
+ * @param {Object} response - Raw response from Bedrock model
178
+ * @param {string} modelFamily - Model family from detectModelFamily()
179
+ * @param {string} modelId - Full Bedrock model ID
180
+ * @returns {Object} Response in Anthropic format
181
+ */
182
+ function convertBedrockResponseToAnthropic(response, modelFamily, modelId) {
183
+ switch (modelFamily) {
184
+ case "claude":
185
+ // Claude models return native Anthropic format
186
+ // No conversion needed - pass through directly
187
+ return response;
188
+
189
+ case "titan":
190
+ // Convert Titan response to Anthropic format
191
+ return {
192
+ id: `bedrock-titan-${Date.now()}`,
193
+ type: "message",
194
+ role: "assistant",
195
+ model: modelId,
196
+ content: [
197
+ {
198
+ type: "text",
199
+ text: response.results?.[0]?.outputText || "",
200
+ },
201
+ ],
202
+ stop_reason: response.results?.[0]?.completionReason === "FINISH" ? "end_turn" : "max_tokens",
203
+ usage: {
204
+ input_tokens: response.inputTextTokenCount || 0,
205
+ output_tokens: response.results?.[0]?.tokenCount || 0,
206
+ },
207
+ };
208
+
209
+ case "llama":
210
+ // Convert Llama response to Anthropic format
211
+ return {
212
+ id: `bedrock-llama-${Date.now()}`,
213
+ type: "message",
214
+ role: "assistant",
215
+ model: modelId,
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: response.generation || "",
220
+ },
221
+ ],
222
+ stop_reason: response.stop_reason === "stop" ? "end_turn" : "max_tokens",
223
+ usage: {
224
+ input_tokens: response.prompt_token_count || 0,
225
+ output_tokens: response.generation_token_count || 0,
226
+ },
227
+ };
228
+
229
+ case "jurassic":
230
+ // Convert Jurassic response to Anthropic format
231
+ return {
232
+ id: `bedrock-jurassic-${Date.now()}`,
233
+ type: "message",
234
+ role: "assistant",
235
+ model: modelId,
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: response.completions?.[0]?.data?.text || "",
240
+ },
241
+ ],
242
+ stop_reason: response.completions?.[0]?.finishReason?.reason === "endoftext" ? "end_turn" : "max_tokens",
243
+ usage: {
244
+ input_tokens: 0, // Jurassic doesn't provide input token count
245
+ output_tokens: response.completions?.[0]?.data?.tokens?.length || 0,
246
+ },
247
+ };
248
+
249
+ case "cohere":
250
+ // Convert Cohere response to Anthropic format
251
+ return {
252
+ id: `bedrock-cohere-${Date.now()}`,
253
+ type: "message",
254
+ role: "assistant",
255
+ model: modelId,
256
+ content: [
257
+ {
258
+ type: "text",
259
+ text: response.generations?.[0]?.text || "",
260
+ },
261
+ ],
262
+ stop_reason: response.generations?.[0]?.finish_reason === "COMPLETE" ? "end_turn" : "max_tokens",
263
+ usage: {
264
+ input_tokens: 0, // Cohere doesn't provide token counts in basic response
265
+ output_tokens: 0,
266
+ },
267
+ };
268
+
269
+ case "mistral":
270
+ // Convert Mistral response to Anthropic format
271
+ return {
272
+ id: `bedrock-mistral-${Date.now()}`,
273
+ type: "message",
274
+ role: "assistant",
275
+ model: modelId,
276
+ content: [
277
+ {
278
+ type: "text",
279
+ text: response.outputs?.[0]?.text || "",
280
+ },
281
+ ],
282
+ stop_reason: response.outputs?.[0]?.stop_reason === "stop" ? "end_turn" : "max_tokens",
283
+ usage: {
284
+ input_tokens: 0, // Mistral doesn't provide token counts
285
+ output_tokens: 0,
286
+ },
287
+ };
288
+
289
+ default:
290
+ throw new Error(`Unsupported model family: ${modelFamily}`);
291
+ }
292
+ }
293
+
294
+ module.exports = {
295
+ detectModelFamily,
296
+ convertAnthropicToBedrockFormat,
297
+ convertBedrockResponseToAnthropic,
298
+ };
@@ -7,6 +7,11 @@ const { getMetricsCollector } = require("../observability/metrics");
7
7
  const logger = require("../logger");
8
8
  const { STANDARD_TOOLS } = require("./standard-tools");
9
9
  const { convertAnthropicToolsToOpenRouter } = require("./openrouter-utils");
10
+ const {
11
+ detectModelFamily,
12
+ convertAnthropicToBedrockFormat,
13
+ convertBedrockResponseToAnthropic
14
+ } = require("./bedrock-utils");
10
15
 
11
16
 
12
17
 
@@ -574,6 +579,262 @@ async function invokeLlamaCpp(body) {
574
579
  return performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
575
580
  }
576
581
 
582
+ async function invokeLMStudio(body) {
583
+ if (!config.lmstudio?.endpoint) {
584
+ throw new Error("LM Studio endpoint is not configured.");
585
+ }
586
+
587
+ const {
588
+ convertAnthropicToolsToOpenRouter,
589
+ convertAnthropicMessagesToOpenRouter
590
+ } = require("./openrouter-utils");
591
+
592
+ const endpoint = `${config.lmstudio.endpoint}/v1/chat/completions`;
593
+ const headers = {
594
+ "Content-Type": "application/json",
595
+ };
596
+
597
+ // Add API key if configured (for secured LM Studio servers)
598
+ if (config.lmstudio.apiKey) {
599
+ headers["Authorization"] = `Bearer ${config.lmstudio.apiKey}`;
600
+ }
601
+
602
+ // Convert messages to OpenAI format
603
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
604
+
605
+ // Handle system message
606
+ if (body.system) {
607
+ messages.unshift({ role: "system", content: body.system });
608
+ }
609
+
610
+ const lmstudioBody = {
611
+ messages,
612
+ temperature: body.temperature ?? 0.7,
613
+ max_tokens: body.max_tokens ?? 4096,
614
+ top_p: body.top_p ?? 1.0,
615
+ stream: body.stream ?? false
616
+ };
617
+
618
+ // Inject standard tools if client didn't send any
619
+ let toolsToSend = body.tools;
620
+ let toolsInjected = false;
621
+
622
+ if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
623
+ toolsToSend = STANDARD_TOOLS;
624
+ toolsInjected = true;
625
+ logger.info({
626
+ injectedToolCount: STANDARD_TOOLS.length,
627
+ injectedToolNames: STANDARD_TOOLS.map(t => t.name),
628
+ reason: "Client did not send tools (passthrough mode)"
629
+ }, "=== INJECTING STANDARD TOOLS (LM Studio) ===");
630
+ }
631
+
632
+ if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
633
+ lmstudioBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
634
+ lmstudioBody.tool_choice = "auto";
635
+ logger.info({
636
+ toolCount: toolsToSend.length,
637
+ toolNames: toolsToSend.map(t => t.name),
638
+ toolsInjected
639
+ }, "=== SENDING TOOLS TO LM STUDIO ===");
640
+ }
641
+
642
+ logger.info({
643
+ endpoint,
644
+ hasTools: !!lmstudioBody.tools,
645
+ toolCount: lmstudioBody.tools?.length || 0,
646
+ temperature: lmstudioBody.temperature,
647
+ max_tokens: lmstudioBody.max_tokens,
648
+ }, "=== LM STUDIO REQUEST ===");
649
+
650
+ return performJsonRequest(endpoint, { headers, body: lmstudioBody }, "LM Studio");
651
+ }
652
+
653
+ async function invokeBedrock(body) {
654
+ // 1. Validate Bearer token
655
+ if (!config.bedrock?.apiKey) {
656
+ throw new Error(
657
+ "AWS Bedrock requires AWS_BEDROCK_API_KEY (Bearer token). " +
658
+ "Generate from AWS Console → Bedrock → API Keys, then set AWS_BEDROCK_API_KEY in your .env file."
659
+ );
660
+ }
661
+
662
+ const bearerToken = config.bedrock.apiKey;
663
+ logger.info({ authMethod: "Bearer Token" }, "=== BEDROCK AUTH ===");
664
+
665
+ // 2. Inject standard tools if needed
666
+ let toolsToSend = body.tools;
667
+ let toolsInjected = false;
668
+
669
+ if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
670
+ toolsToSend = STANDARD_TOOLS;
671
+ toolsInjected = true;
672
+ logger.info({
673
+ injectedToolCount: STANDARD_TOOLS.length,
674
+ injectedToolNames: STANDARD_TOOLS.map(t => t.name),
675
+ reason: "Client did not send tools (passthrough mode)"
676
+ }, "=== INJECTING STANDARD TOOLS (Bedrock) ===");
677
+ }
678
+
679
+ const bedrockBody = { ...body, tools: toolsToSend };
680
+
681
+ // 4. Detect model family and convert format
682
+ const modelId = config.bedrock.modelId;
683
+ const modelFamily = detectModelFamily(modelId);
684
+
685
+ logger.info({
686
+ modelId,
687
+ modelFamily,
688
+ hasTools: !!bedrockBody.tools,
689
+ toolCount: bedrockBody.tools?.length || 0,
690
+ streaming: body.stream || false,
691
+ }, "=== BEDROCK REQUEST (FETCH) ===");
692
+
693
+ // 5. Convert to Bedrock Converse API format (simpler, more universal)
694
+ // Bedrock Converse API only allows 'user' and 'assistant' roles in messages array
695
+
696
+ // Extract system messages from messages array (if any)
697
+ const systemMessages = bedrockBody.messages.filter(msg => msg.role === 'system');
698
+
699
+ const converseBody = {
700
+ messages: bedrockBody.messages
701
+ .filter(msg => msg.role !== 'system') // Filter out system messages
702
+ .map(msg => ({
703
+ role: msg.role,
704
+ content: Array.isArray(msg.content)
705
+ ? msg.content.map(c => ({ text: c.text || c.content || "" }))
706
+ : [{ text: msg.content }]
707
+ }))
708
+ };
709
+
710
+ // Add system prompt (from Anthropic system field OR extracted from messages)
711
+ if (bedrockBody.system) {
712
+ converseBody.system = [{ text: bedrockBody.system }];
713
+ } else if (systemMessages.length > 0) {
714
+ // If system messages were in the messages array, use the first one
715
+ const systemContent = Array.isArray(systemMessages[0].content)
716
+ ? systemMessages[0].content.map(c => c.text || c.content || "").join("\n")
717
+ : systemMessages[0].content;
718
+ converseBody.system = [{ text: systemContent }];
719
+ }
720
+
721
+ // Add inference config
722
+ if (bedrockBody.max_tokens) {
723
+ converseBody.inferenceConfig = {
724
+ maxTokens: bedrockBody.max_tokens,
725
+ temperature: bedrockBody.temperature,
726
+ topP: bedrockBody.top_p,
727
+ };
728
+ }
729
+
730
+ // Add tools if present
731
+ if (bedrockBody.tools && bedrockBody.tools.length > 0) {
732
+ converseBody.toolConfig = {
733
+ tools: bedrockBody.tools.map(tool => ({
734
+ toolSpec: {
735
+ name: tool.name,
736
+ description: tool.description,
737
+ inputSchema: {
738
+ json: tool.input_schema
739
+ }
740
+ }
741
+ }))
742
+ };
743
+ }
744
+
745
+ // 6. Construct Bedrock Converse API endpoint
746
+ const path = `/model/${modelId}/converse`;
747
+ const host = `bedrock-runtime.${config.bedrock.region}.amazonaws.com`;
748
+ const endpoint = `https://${host}${path}`;
749
+
750
+ logger.info({
751
+ endpoint,
752
+ authMethod: "Bearer Token",
753
+ hasSystem: !!converseBody.system,
754
+ hasTools: !!converseBody.toolConfig,
755
+ messageCount: converseBody.messages.length
756
+ }, "=== BEDROCK CONVERSE API REQUEST ===");
757
+
758
+ // 7. Prepare request headers with Bearer token
759
+ const requestHeaders = {
760
+ "Content-Type": "application/json",
761
+ "Authorization": `Bearer ${bearerToken}`
762
+ };
763
+
764
+ // 8. Make the Converse API request
765
+ try {
766
+ const response = await performJsonRequest(endpoint, {
767
+ headers: requestHeaders,
768
+ body: converseBody // Pass object, performJsonRequest will stringify it
769
+ }, "Bedrock"); // Add provider label for logging
770
+
771
+ if (!response.ok) {
772
+ const errorText = response.text; // Use property, not method
773
+ logger.error({
774
+ status: response.status,
775
+ error: errorText
776
+ }, "=== BEDROCK CONVERSE API ERROR ===");
777
+ throw new Error(`Bedrock Converse API failed: ${response.status} ${errorText}`);
778
+ }
779
+
780
+ // Parse Converse API response (already parsed by performJsonRequest)
781
+ const converseResponse = response.json; // Use property, not method
782
+
783
+ logger.info({
784
+ stopReason: converseResponse.stopReason,
785
+ inputTokens: converseResponse.usage?.inputTokens || 0,
786
+ outputTokens: converseResponse.usage?.outputTokens || 0,
787
+ hasToolUse: !!converseResponse.output?.message?.content?.some(c => c.toolUse)
788
+ }, "=== BEDROCK CONVERSE API RESPONSE ===");
789
+
790
+ // Convert Converse API response to Anthropic format
791
+ const message = converseResponse.output.message;
792
+ const anthropicResponse = {
793
+ id: `bedrock-${Date.now()}`,
794
+ type: "message",
795
+ role: message.role,
796
+ model: modelId,
797
+ content: message.content.map(item => {
798
+ if (item.text) {
799
+ return { type: "text", text: item.text };
800
+ } else if (item.toolUse) {
801
+ return {
802
+ type: "tool_use",
803
+ id: item.toolUse.toolUseId,
804
+ name: item.toolUse.name,
805
+ input: item.toolUse.input
806
+ };
807
+ }
808
+ return item;
809
+ }),
810
+ stop_reason: converseResponse.stopReason === "end_turn" ? "end_turn" :
811
+ converseResponse.stopReason === "tool_use" ? "tool_use" :
812
+ converseResponse.stopReason === "max_tokens" ? "max_tokens" : "end_turn",
813
+ usage: {
814
+ input_tokens: converseResponse.usage?.inputTokens || 0,
815
+ output_tokens: converseResponse.usage?.outputTokens || 0,
816
+ },
817
+ };
818
+
819
+ return {
820
+ ok: true,
821
+ status: 200,
822
+ json: anthropicResponse,
823
+ actualProvider: "bedrock",
824
+ modelFamily,
825
+ };
826
+ } catch (e) {
827
+ logger.error({
828
+ error: e.message,
829
+ modelId,
830
+ region: config.bedrock.region,
831
+ endpoint,
832
+ stack: e.stack
833
+ }, "=== BEDROCK CONVERSE API ERROR ===");
834
+ throw e;
835
+ }
836
+ }
837
+
577
838
  async function invokeModel(body, options = {}) {
578
839
  const { determineProvider, isFallbackEnabled, getFallbackProvider } = require("./routing");
579
840
  const metricsCollector = getMetricsCollector();
@@ -617,6 +878,10 @@ async function invokeModel(body, options = {}) {
617
878
  return await invokeOpenAI(body);
618
879
  } else if (initialProvider === "llamacpp") {
619
880
  return await invokeLlamaCpp(body);
881
+ } else if (initialProvider === "lmstudio") {
882
+ return await invokeLMStudio(body);
883
+ } else if (initialProvider === "bedrock") {
884
+ return await invokeBedrock(body);
620
885
  }
621
886
  return await invokeDatabricks(body);
622
887
  });