ultravisor-beacon 0.0.1

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,966 @@
1
+ /**
2
+ * Ultravisor Beacon Provider — LLM
3
+ *
4
+ * Built-in provider that wraps LLM API calls for multiple backends:
5
+ * - openai — OpenAI API (GPT-4, etc.)
6
+ * - anthropic — Anthropic API (Claude)
7
+ * - ollama — Local Ollama instance
8
+ * - openai-compatible — Any OpenAI-compatible API
9
+ *
10
+ * Capability: 'LLM'
11
+ * Actions: 'ChatCompletion' — Send messages, get completion
12
+ * 'Embedding' — Generate text embeddings
13
+ * 'ToolUse' — Chat completion with tool definitions
14
+ *
15
+ * Provider config:
16
+ * Backend {string} — 'openai' | 'anthropic' | 'ollama' | 'openai-compatible'
17
+ * BaseURL {string} — API endpoint base URL
18
+ * APIKey {string} — API key or $ENV_VAR_NAME for env resolution
19
+ * Model {string} — Default model name
20
+ * DefaultParameters {object} — { Temperature, MaxTokens, TopP }
21
+ * TimeoutMs {number} — Per-request timeout (default: 120000)
22
+ */
23
+
24
+ const libHttp = require('http');
25
+ const libHttps = require('https');
26
+ const libUrl = require('url');
27
+
28
+ const libBeaconCapabilityProvider = require('../Ultravisor-Beacon-CapabilityProvider.cjs');
29
+
30
+ class UltravisorBeaconProviderLLM extends libBeaconCapabilityProvider
31
+ {
32
+ constructor(pProviderConfig)
33
+ {
34
+ super(pProviderConfig);
35
+
36
+ this.Name = 'LLM';
37
+ this.Capability = 'LLM';
38
+
39
+ this._Backend = this._ProviderConfig.Backend || 'openai';
40
+ this._BaseURL = this._ProviderConfig.BaseURL || '';
41
+ this._APIKeyConfig = this._ProviderConfig.APIKey || '';
42
+ this._Model = this._ProviderConfig.Model || '';
43
+ this._DefaultParameters = this._ProviderConfig.DefaultParameters || {};
44
+ this._TimeoutMs = this._ProviderConfig.TimeoutMs || 120000;
45
+
46
+ // Resolved at initialize time
47
+ this._ResolvedAPIKey = '';
48
+ }
49
+
50
+ get actions()
51
+ {
52
+ return {
53
+ 'ChatCompletion':
54
+ {
55
+ Description: 'Send messages to an LLM and receive a completion.',
56
+ SettingsSchema:
57
+ [
58
+ { Name: 'Messages', DataType: 'String', Required: false, Description: 'JSON array of message objects [{role, content}]' },
59
+ { Name: 'SystemPrompt', DataType: 'String', Required: false, Description: 'System prompt text (prepended as system message)' },
60
+ { Name: 'Model', DataType: 'String', Required: false, Description: 'Override model name' },
61
+ { Name: 'Temperature', DataType: 'Number', Required: false, Description: 'Sampling temperature (0-2)' },
62
+ { Name: 'MaxTokens', DataType: 'Number', Required: false, Description: 'Maximum tokens to generate' },
63
+ { Name: 'TopP', DataType: 'Number', Required: false, Description: 'Nucleus sampling parameter' },
64
+ { Name: 'StopSequences', DataType: 'String', Required: false, Description: 'JSON array of stop sequences' },
65
+ { Name: 'ResponseFormat', DataType: 'String', Required: false, Description: '"text" or "json_object"' }
66
+ ]
67
+ },
68
+ 'Embedding':
69
+ {
70
+ Description: 'Generate embeddings for text input.',
71
+ SettingsSchema:
72
+ [
73
+ { Name: 'Text', DataType: 'String', Required: true, Description: 'Text to embed (string or JSON array for batch)' },
74
+ { Name: 'Model', DataType: 'String', Required: false, Description: 'Override embedding model' }
75
+ ]
76
+ },
77
+ 'ToolUse':
78
+ {
79
+ Description: 'Chat completion with tool/function definitions.',
80
+ SettingsSchema:
81
+ [
82
+ { Name: 'Messages', DataType: 'String', Required: true, Description: 'JSON array of message objects' },
83
+ { Name: 'Tools', DataType: 'String', Required: true, Description: 'JSON array of tool definitions' },
84
+ { Name: 'Model', DataType: 'String', Required: false, Description: 'Override model name' },
85
+ { Name: 'ToolChoice', DataType: 'String', Required: false, Description: '"auto", "none", or specific tool name' },
86
+ { Name: 'Temperature', DataType: 'Number', Required: false, Description: 'Sampling temperature' },
87
+ { Name: 'MaxTokens', DataType: 'Number', Required: false, Description: 'Maximum tokens to generate' }
88
+ ]
89
+ }
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Resolve the API key from config. Supports $ENV_VAR_NAME syntax.
95
+ */
96
+ _resolveAPIKey(pKeyConfig)
97
+ {
98
+ if (!pKeyConfig)
99
+ {
100
+ return '';
101
+ }
102
+
103
+ if (pKeyConfig.startsWith('$'))
104
+ {
105
+ let tmpEnvVar = pKeyConfig.substring(1);
106
+ return process.env[tmpEnvVar] || '';
107
+ }
108
+
109
+ return pKeyConfig;
110
+ }
111
+
112
+ /**
113
+ * Validate connectivity during initialization.
114
+ */
115
+ initialize(fCallback)
116
+ {
117
+ this._ResolvedAPIKey = this._resolveAPIKey(this._APIKeyConfig);
118
+
119
+ if (!this._BaseURL)
120
+ {
121
+ console.warn(`[LLM] No BaseURL configured for provider "${this.Name}".`);
122
+ }
123
+
124
+ if (!this._Model)
125
+ {
126
+ console.warn(`[LLM] No default Model configured for provider "${this.Name}".`);
127
+ }
128
+
129
+ // For Ollama, check that the server is reachable
130
+ if (this._Backend === 'ollama' && this._BaseURL)
131
+ {
132
+ let tmpParsed = new URL(this._BaseURL);
133
+ let tmpLib = tmpParsed.protocol === 'https:' ? libHttps : libHttp;
134
+ let tmpRequest = tmpLib.request(
135
+ {
136
+ hostname: tmpParsed.hostname,
137
+ port: tmpParsed.port,
138
+ path: '/api/tags',
139
+ method: 'GET',
140
+ timeout: 5000
141
+ },
142
+ function (pResponse)
143
+ {
144
+ // Consume response body to free the socket
145
+ pResponse.resume();
146
+ console.log(`[LLM] Ollama server reachable at ${tmpParsed.hostname}:${tmpParsed.port}`);
147
+ return fCallback(null);
148
+ });
149
+
150
+ tmpRequest.on('error', function (pError)
151
+ {
152
+ console.warn(`[LLM] Ollama server not reachable at ${tmpParsed.hostname}:${tmpParsed.port}: ${pError.message}`);
153
+ // Non-fatal — the server may come online later
154
+ return fCallback(null);
155
+ });
156
+
157
+ tmpRequest.on('timeout', function ()
158
+ {
159
+ tmpRequest.destroy();
160
+ console.warn(`[LLM] Ollama server connection timed out.`);
161
+ return fCallback(null);
162
+ });
163
+
164
+ tmpRequest.end();
165
+ return;
166
+ }
167
+
168
+ console.log(`[LLM] Provider initialized: backend=${this._Backend}, model=${this._Model}`);
169
+ return fCallback(null);
170
+ }
171
+
172
+ /**
173
+ * Route execution to the appropriate action handler.
174
+ */
175
+ execute(pAction, pWorkItem, pContext, fCallback, fReportProgress)
176
+ {
177
+ switch (pAction)
178
+ {
179
+ case 'ChatCompletion':
180
+ return this._executeChatCompletion(pWorkItem, pContext, fCallback, fReportProgress);
181
+ case 'Embedding':
182
+ return this._executeEmbedding(pWorkItem, pContext, fCallback, fReportProgress);
183
+ case 'ToolUse':
184
+ return this._executeToolUse(pWorkItem, pContext, fCallback, fReportProgress);
185
+ default:
186
+ return fCallback(new Error(`LLM Provider: unknown action "${pAction}".`));
187
+ }
188
+ }
189
+
190
+ // ── ChatCompletion ──────────────────────────────────────────
191
+
192
+ _executeChatCompletion(pWorkItem, pContext, fCallback, fReportProgress)
193
+ {
194
+ let tmpSettings = pWorkItem.Settings || {};
195
+ let tmpMessages = this._parseMessages(tmpSettings);
196
+
197
+ if (!tmpMessages || tmpMessages.length === 0)
198
+ {
199
+ return fCallback(null, {
200
+ Outputs: { Content: '', Model: '', FinishReason: 'error', PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, Result: '' },
201
+ Log: ['LLM ChatCompletion: no messages provided.']
202
+ });
203
+ }
204
+
205
+ let tmpModel = tmpSettings.Model || this._Model;
206
+ let tmpRequestBody = this._buildChatRequestBody(tmpMessages, tmpModel, tmpSettings, false);
207
+ let tmpRequestOptions = this._buildRequestOptions('chat', tmpRequestBody);
208
+
209
+ console.log(` [LLM] ChatCompletion: model=${tmpModel}, messages=${tmpMessages.length}`);
210
+
211
+ this._makeRequest(tmpRequestOptions, tmpRequestBody, fReportProgress,
212
+ (pError, pResponseBody) =>
213
+ {
214
+ if (pError)
215
+ {
216
+ return fCallback(null, {
217
+ Outputs: { Content: '', Model: tmpModel, FinishReason: 'error', PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, Result: '' },
218
+ Log: [`LLM ChatCompletion failed: ${pError.message}`]
219
+ });
220
+ }
221
+
222
+ let tmpParsed = this._parseChatResponse(pResponseBody);
223
+
224
+ return fCallback(null, {
225
+ Outputs:
226
+ {
227
+ Content: tmpParsed.Content,
228
+ Model: tmpParsed.Model || tmpModel,
229
+ FinishReason: tmpParsed.FinishReason,
230
+ PromptTokens: tmpParsed.PromptTokens,
231
+ CompletionTokens: tmpParsed.CompletionTokens,
232
+ TotalTokens: tmpParsed.TotalTokens,
233
+ Result: tmpParsed.Content
234
+ },
235
+ Log: [`LLM ChatCompletion: model=${tmpParsed.Model || tmpModel}, tokens=${tmpParsed.TotalTokens}, finish=${tmpParsed.FinishReason}`]
236
+ });
237
+ });
238
+ }
239
+
240
+ // ── Embedding ───────────────────────────────────────────────
241
+
242
+ _executeEmbedding(pWorkItem, pContext, fCallback, fReportProgress)
243
+ {
244
+ let tmpSettings = pWorkItem.Settings || {};
245
+ let tmpText = tmpSettings.Text || '';
246
+
247
+ if (!tmpText)
248
+ {
249
+ return fCallback(null, {
250
+ Outputs: { Embedding: '[]', Dimensions: 0, Model: '', Result: '' },
251
+ Log: ['LLM Embedding: no text provided.']
252
+ });
253
+ }
254
+
255
+ let tmpModel = tmpSettings.Model || this._Model;
256
+ let tmpRequestBody = this._buildEmbeddingRequestBody(tmpText, tmpModel);
257
+ let tmpRequestOptions = this._buildRequestOptions('embedding', tmpRequestBody);
258
+
259
+ console.log(` [LLM] Embedding: model=${tmpModel}`);
260
+
261
+ this._makeRequest(tmpRequestOptions, tmpRequestBody, fReportProgress,
262
+ (pError, pResponseBody) =>
263
+ {
264
+ if (pError)
265
+ {
266
+ return fCallback(null, {
267
+ Outputs: { Embedding: '[]', Dimensions: 0, Model: tmpModel, Result: '' },
268
+ Log: [`LLM Embedding failed: ${pError.message}`]
269
+ });
270
+ }
271
+
272
+ let tmpParsed = this._parseEmbeddingResponse(pResponseBody);
273
+
274
+ return fCallback(null, {
275
+ Outputs:
276
+ {
277
+ Embedding: tmpParsed.Embedding,
278
+ Dimensions: tmpParsed.Dimensions,
279
+ Model: tmpParsed.Model || tmpModel,
280
+ Result: tmpParsed.Embedding
281
+ },
282
+ Log: [`LLM Embedding: model=${tmpParsed.Model || tmpModel}, dimensions=${tmpParsed.Dimensions}`]
283
+ });
284
+ });
285
+ }
286
+
287
+ // ── ToolUse ─────────────────────────────────────────────────
288
+
289
+ _executeToolUse(pWorkItem, pContext, fCallback, fReportProgress)
290
+ {
291
+ let tmpSettings = pWorkItem.Settings || {};
292
+ let tmpMessages = this._parseMessages(tmpSettings);
293
+ let tmpTools = this._safeParseJSON(tmpSettings.Tools, []);
294
+
295
+ if (!tmpMessages || tmpMessages.length === 0)
296
+ {
297
+ return fCallback(null, {
298
+ Outputs: { Content: '', ToolCalls: '[]', Model: '', FinishReason: 'error', PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, Result: '' },
299
+ Log: ['LLM ToolUse: no messages provided.']
300
+ });
301
+ }
302
+
303
+ if (!tmpTools || tmpTools.length === 0)
304
+ {
305
+ return fCallback(null, {
306
+ Outputs: { Content: '', ToolCalls: '[]', Model: '', FinishReason: 'error', PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, Result: '' },
307
+ Log: ['LLM ToolUse: no tools provided.']
308
+ });
309
+ }
310
+
311
+ let tmpModel = tmpSettings.Model || this._Model;
312
+ let tmpRequestBody = this._buildChatRequestBody(tmpMessages, tmpModel, tmpSettings, true);
313
+
314
+ // Add tools to the request body
315
+ this._addToolsToRequestBody(tmpRequestBody, tmpTools, tmpSettings.ToolChoice);
316
+
317
+ let tmpRequestOptions = this._buildRequestOptions('chat', tmpRequestBody);
318
+
319
+ console.log(` [LLM] ToolUse: model=${tmpModel}, messages=${tmpMessages.length}, tools=${tmpTools.length}`);
320
+
321
+ this._makeRequest(tmpRequestOptions, tmpRequestBody, fReportProgress,
322
+ (pError, pResponseBody) =>
323
+ {
324
+ if (pError)
325
+ {
326
+ return fCallback(null, {
327
+ Outputs: { Content: '', ToolCalls: '[]', Model: tmpModel, FinishReason: 'error', PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, Result: '' },
328
+ Log: [`LLM ToolUse failed: ${pError.message}`]
329
+ });
330
+ }
331
+
332
+ let tmpParsed = this._parseToolUseResponse(pResponseBody);
333
+
334
+ return fCallback(null, {
335
+ Outputs:
336
+ {
337
+ Content: tmpParsed.Content,
338
+ ToolCalls: tmpParsed.ToolCalls,
339
+ Model: tmpParsed.Model || tmpModel,
340
+ FinishReason: tmpParsed.FinishReason,
341
+ PromptTokens: tmpParsed.PromptTokens,
342
+ CompletionTokens: tmpParsed.CompletionTokens,
343
+ TotalTokens: tmpParsed.TotalTokens,
344
+ Result: tmpParsed.Content
345
+ },
346
+ Log: [`LLM ToolUse: model=${tmpParsed.Model || tmpModel}, tokens=${tmpParsed.TotalTokens}, finish=${tmpParsed.FinishReason}, tool_calls=${tmpParsed.ToolCallCount}`]
347
+ });
348
+ });
349
+ }
350
+
351
+ // ── Message Parsing ─────────────────────────────────────────
352
+
353
+ /**
354
+ * Parse messages from settings. Supports Messages JSON array
355
+ * and SystemPrompt convenience field.
356
+ */
357
+ _parseMessages(pSettings)
358
+ {
359
+ let tmpMessages = [];
360
+
361
+ // Parse Messages JSON if provided
362
+ if (pSettings.Messages)
363
+ {
364
+ let tmpParsed = this._safeParseJSON(pSettings.Messages, null);
365
+
366
+ if (Array.isArray(tmpParsed))
367
+ {
368
+ tmpMessages = tmpParsed;
369
+ }
370
+ else if (typeof pSettings.Messages === 'string' && pSettings.Messages.length > 0)
371
+ {
372
+ // Treat plain string as a single user message
373
+ tmpMessages.push({ role: 'user', content: pSettings.Messages });
374
+ }
375
+ }
376
+
377
+ // Prepend SystemPrompt if provided and not already in messages
378
+ if (pSettings.SystemPrompt)
379
+ {
380
+ let tmpHasSystem = tmpMessages.some(function (pMsg) { return pMsg.role === 'system'; });
381
+
382
+ if (!tmpHasSystem)
383
+ {
384
+ tmpMessages.unshift({ role: 'system', content: pSettings.SystemPrompt });
385
+ }
386
+ }
387
+
388
+ return tmpMessages;
389
+ }
390
+
391
+ // ── Request Building ────────────────────────────────────────
392
+
393
+ /**
394
+ * Build the chat completion request body for the configured backend.
395
+ */
396
+ _buildChatRequestBody(pMessages, pModel, pSettings, pIsToolUse)
397
+ {
398
+ let tmpTemperature = (pSettings.Temperature !== undefined && pSettings.Temperature !== '')
399
+ ? parseFloat(pSettings.Temperature)
400
+ : (this._DefaultParameters.Temperature !== undefined ? this._DefaultParameters.Temperature : undefined);
401
+
402
+ let tmpMaxTokens = (pSettings.MaxTokens !== undefined && pSettings.MaxTokens !== '')
403
+ ? parseInt(pSettings.MaxTokens, 10)
404
+ : (this._DefaultParameters.MaxTokens !== undefined ? this._DefaultParameters.MaxTokens : undefined);
405
+
406
+ let tmpTopP = (pSettings.TopP !== undefined && pSettings.TopP !== '')
407
+ ? parseFloat(pSettings.TopP)
408
+ : (this._DefaultParameters.TopP !== undefined ? this._DefaultParameters.TopP : undefined);
409
+
410
+ let tmpStopSequences = this._safeParseJSON(pSettings.StopSequences, null);
411
+
412
+ if (this._Backend === 'anthropic')
413
+ {
414
+ return this._buildAnthropicChatBody(pMessages, pModel, tmpTemperature, tmpMaxTokens, tmpTopP, tmpStopSequences);
415
+ }
416
+
417
+ if (this._Backend === 'ollama')
418
+ {
419
+ return this._buildOllamaChatBody(pMessages, pModel, tmpTemperature, tmpMaxTokens, tmpTopP, tmpStopSequences);
420
+ }
421
+
422
+ // OpenAI and openai-compatible
423
+ return this._buildOpenAIChatBody(pMessages, pModel, tmpTemperature, tmpMaxTokens, tmpTopP, tmpStopSequences, pSettings.ResponseFormat);
424
+ }
425
+
426
+ _buildOpenAIChatBody(pMessages, pModel, pTemperature, pMaxTokens, pTopP, pStopSequences, pResponseFormat)
427
+ {
428
+ let tmpBody = {
429
+ model: pModel,
430
+ messages: pMessages
431
+ };
432
+
433
+ if (pTemperature !== undefined)
434
+ {
435
+ tmpBody.temperature = pTemperature;
436
+ }
437
+ if (pMaxTokens !== undefined)
438
+ {
439
+ tmpBody.max_tokens = pMaxTokens;
440
+ }
441
+ if (pTopP !== undefined)
442
+ {
443
+ tmpBody.top_p = pTopP;
444
+ }
445
+ if (pStopSequences)
446
+ {
447
+ tmpBody.stop = pStopSequences;
448
+ }
449
+ if (pResponseFormat === 'json_object')
450
+ {
451
+ tmpBody.response_format = { type: 'json_object' };
452
+ }
453
+
454
+ return tmpBody;
455
+ }
456
+
457
+ _buildAnthropicChatBody(pMessages, pModel, pTemperature, pMaxTokens, pTopP, pStopSequences)
458
+ {
459
+ // Anthropic separates system from messages
460
+ let tmpSystem = '';
461
+ let tmpMessages = [];
462
+
463
+ for (let i = 0; i < pMessages.length; i++)
464
+ {
465
+ if (pMessages[i].role === 'system')
466
+ {
467
+ tmpSystem = (tmpSystem ? tmpSystem + '\n' : '') + pMessages[i].content;
468
+ }
469
+ else
470
+ {
471
+ tmpMessages.push(pMessages[i]);
472
+ }
473
+ }
474
+
475
+ let tmpBody = {
476
+ model: pModel,
477
+ messages: tmpMessages,
478
+ max_tokens: pMaxTokens || 4096
479
+ };
480
+
481
+ if (tmpSystem)
482
+ {
483
+ tmpBody.system = tmpSystem;
484
+ }
485
+ if (pTemperature !== undefined)
486
+ {
487
+ tmpBody.temperature = pTemperature;
488
+ }
489
+ if (pTopP !== undefined)
490
+ {
491
+ tmpBody.top_p = pTopP;
492
+ }
493
+ if (pStopSequences)
494
+ {
495
+ tmpBody.stop_sequences = pStopSequences;
496
+ }
497
+
498
+ return tmpBody;
499
+ }
500
+
501
+ _buildOllamaChatBody(pMessages, pModel, pTemperature, pMaxTokens, pTopP, pStopSequences)
502
+ {
503
+ let tmpBody = {
504
+ model: pModel,
505
+ messages: pMessages,
506
+ stream: false
507
+ };
508
+
509
+ let tmpOptions = {};
510
+
511
+ if (pTemperature !== undefined)
512
+ {
513
+ tmpOptions.temperature = pTemperature;
514
+ }
515
+ if (pMaxTokens !== undefined)
516
+ {
517
+ tmpOptions.num_predict = pMaxTokens;
518
+ }
519
+ if (pTopP !== undefined)
520
+ {
521
+ tmpOptions.top_p = pTopP;
522
+ }
523
+ if (pStopSequences)
524
+ {
525
+ tmpOptions.stop = pStopSequences;
526
+ }
527
+
528
+ if (Object.keys(tmpOptions).length > 0)
529
+ {
530
+ tmpBody.options = tmpOptions;
531
+ }
532
+
533
+ return tmpBody;
534
+ }
535
+
536
+ /**
537
+ * Build embedding request body.
538
+ */
539
+ _buildEmbeddingRequestBody(pText, pModel)
540
+ {
541
+ if (this._Backend === 'ollama')
542
+ {
543
+ return {
544
+ model: pModel,
545
+ prompt: pText
546
+ };
547
+ }
548
+
549
+ // OpenAI / openai-compatible / Anthropic (uses voyage via OpenAI-compat)
550
+ return {
551
+ model: pModel,
552
+ input: pText
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Add tool definitions to an existing request body.
558
+ */
559
+ _addToolsToRequestBody(pRequestBody, pTools, pToolChoice)
560
+ {
561
+ if (this._Backend === 'anthropic')
562
+ {
563
+ // Anthropic uses a different tool format
564
+ pRequestBody.tools = pTools.map(function (pTool)
565
+ {
566
+ if (pTool.type === 'function')
567
+ {
568
+ // Convert from OpenAI format to Anthropic format
569
+ return {
570
+ name: pTool.function.name,
571
+ description: pTool.function.description || '',
572
+ input_schema: pTool.function.parameters || {}
573
+ };
574
+ }
575
+ // Already in Anthropic format
576
+ return pTool;
577
+ });
578
+
579
+ if (pToolChoice && pToolChoice !== 'auto' && pToolChoice !== 'none')
580
+ {
581
+ pRequestBody.tool_choice = { type: 'tool', name: pToolChoice };
582
+ }
583
+ else if (pToolChoice === 'none')
584
+ {
585
+ // Anthropic doesn't have a direct 'none' — omit tools instead
586
+ delete pRequestBody.tools;
587
+ }
588
+ else if (pToolChoice === 'auto')
589
+ {
590
+ pRequestBody.tool_choice = { type: 'auto' };
591
+ }
592
+ }
593
+ else
594
+ {
595
+ // OpenAI / openai-compatible / Ollama
596
+ pRequestBody.tools = pTools;
597
+
598
+ if (pToolChoice)
599
+ {
600
+ if (pToolChoice === 'auto' || pToolChoice === 'none')
601
+ {
602
+ pRequestBody.tool_choice = pToolChoice;
603
+ }
604
+ else
605
+ {
606
+ pRequestBody.tool_choice = { type: 'function', function: { name: pToolChoice } };
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ // ── HTTP Request Options ────────────────────────────────────
613
+
614
+ /**
615
+ * Build HTTP request options based on backend and action type.
616
+ *
617
+ * @param {string} pActionType - 'chat' or 'embedding'
618
+ * @param {object} pBody - The request body (used for Content-Length)
619
+ * @returns {{ url: string, options: object, bodyString: string }}
620
+ */
621
+ _buildRequestOptions(pActionType, pBody)
622
+ {
623
+ let tmpBodyString = JSON.stringify(pBody);
624
+ let tmpPath = '';
625
+
626
+ switch (this._Backend)
627
+ {
628
+ case 'anthropic':
629
+ tmpPath = (pActionType === 'embedding') ? '/v1/embeddings' : '/v1/messages';
630
+ break;
631
+
632
+ case 'ollama':
633
+ tmpPath = (pActionType === 'embedding') ? '/api/embeddings' : '/api/chat';
634
+ break;
635
+
636
+ case 'openai':
637
+ case 'openai-compatible':
638
+ default:
639
+ tmpPath = (pActionType === 'embedding') ? '/v1/embeddings' : '/v1/chat/completions';
640
+ break;
641
+ }
642
+
643
+ let tmpParsed = new URL(this._BaseURL);
644
+
645
+ let tmpHeaders = {
646
+ 'Content-Type': 'application/json',
647
+ 'Content-Length': Buffer.byteLength(tmpBodyString)
648
+ };
649
+
650
+ // Set auth headers per backend
651
+ if (this._Backend === 'anthropic')
652
+ {
653
+ if (this._ResolvedAPIKey)
654
+ {
655
+ tmpHeaders['x-api-key'] = this._ResolvedAPIKey;
656
+ }
657
+ tmpHeaders['anthropic-version'] = '2023-06-01';
658
+ }
659
+ else if (this._Backend !== 'ollama')
660
+ {
661
+ // OpenAI / openai-compatible use Bearer token
662
+ if (this._ResolvedAPIKey)
663
+ {
664
+ tmpHeaders['Authorization'] = 'Bearer ' + this._ResolvedAPIKey;
665
+ }
666
+ }
667
+
668
+ return {
669
+ options:
670
+ {
671
+ hostname: tmpParsed.hostname,
672
+ port: tmpParsed.port || (tmpParsed.protocol === 'https:' ? 443 : 80),
673
+ path: tmpPath,
674
+ method: 'POST',
675
+ headers: tmpHeaders,
676
+ timeout: this._TimeoutMs
677
+ },
678
+ bodyString: tmpBodyString,
679
+ protocol: tmpParsed.protocol
680
+ };
681
+ }
682
+
683
+ // ── HTTP Transport ──────────────────────────────────────────
684
+
685
+ /**
686
+ * Execute an HTTP/HTTPS request and return the parsed JSON response.
687
+ *
688
+ * @param {object} pRequestInfo - From _buildRequestOptions
689
+ * @param {object} pBody - Original request body (unused, kept for clarity)
690
+ * @param {function} fReportProgress - Optional progress callback
691
+ * @param {function} fCallback - function(pError, pResponseBody)
692
+ */
693
+ _makeRequest(pRequestInfo, pBody, fReportProgress, fCallback)
694
+ {
695
+ let tmpLib = pRequestInfo.protocol === 'https:' ? libHttps : libHttp;
696
+
697
+ let tmpRequest = tmpLib.request(pRequestInfo.options, function (pResponse)
698
+ {
699
+ let tmpChunks = [];
700
+ let tmpTotalBytes = 0;
701
+
702
+ pResponse.on('data', function (pChunk)
703
+ {
704
+ tmpChunks.push(pChunk);
705
+ tmpTotalBytes += pChunk.length;
706
+
707
+ // Report progress during large responses
708
+ if (fReportProgress && tmpTotalBytes > 0)
709
+ {
710
+ fReportProgress({
711
+ Message: `Receiving response: ${Math.round(tmpTotalBytes / 1024)}KB`,
712
+ Log: []
713
+ });
714
+ }
715
+ });
716
+
717
+ pResponse.on('end', function ()
718
+ {
719
+ let tmpRawBody = Buffer.concat(tmpChunks).toString('utf8');
720
+ let tmpParsedBody = null;
721
+
722
+ try
723
+ {
724
+ tmpParsedBody = JSON.parse(tmpRawBody);
725
+ }
726
+ catch (pParseError)
727
+ {
728
+ return fCallback(new Error(`Failed to parse LLM response as JSON: ${pParseError.message}. Raw: ${tmpRawBody.substring(0, 500)}`));
729
+ }
730
+
731
+ // Check for API error responses
732
+ if (pResponse.statusCode >= 400)
733
+ {
734
+ let tmpErrorMsg = tmpParsedBody.error
735
+ ? (tmpParsedBody.error.message || JSON.stringify(tmpParsedBody.error))
736
+ : `HTTP ${pResponse.statusCode}`;
737
+ return fCallback(new Error(`LLM API error (${pResponse.statusCode}): ${tmpErrorMsg}`));
738
+ }
739
+
740
+ return fCallback(null, tmpParsedBody);
741
+ });
742
+ });
743
+
744
+ tmpRequest.on('error', function (pError)
745
+ {
746
+ return fCallback(new Error(`LLM request failed: ${pError.message}`));
747
+ });
748
+
749
+ tmpRequest.on('timeout', function ()
750
+ {
751
+ tmpRequest.destroy();
752
+ return fCallback(new Error(`LLM request timed out after ${pRequestInfo.options.timeout}ms.`));
753
+ });
754
+
755
+ tmpRequest.write(pRequestInfo.bodyString);
756
+ tmpRequest.end();
757
+ }
758
+
759
+ // ── Response Parsing ────────────────────────────────────────
760
+
761
+ /**
762
+ * Parse a chat completion response into normalized outputs.
763
+ */
764
+ _parseChatResponse(pResponseBody)
765
+ {
766
+ if (this._Backend === 'anthropic')
767
+ {
768
+ return this._parseAnthropicChatResponse(pResponseBody);
769
+ }
770
+
771
+ if (this._Backend === 'ollama')
772
+ {
773
+ return this._parseOllamaChatResponse(pResponseBody);
774
+ }
775
+
776
+ return this._parseOpenAIChatResponse(pResponseBody);
777
+ }
778
+
779
+ _parseOpenAIChatResponse(pBody)
780
+ {
781
+ let tmpChoice = (pBody.choices && pBody.choices.length > 0) ? pBody.choices[0] : {};
782
+ let tmpMessage = tmpChoice.message || {};
783
+ let tmpUsage = pBody.usage || {};
784
+
785
+ return {
786
+ Content: tmpMessage.content || '',
787
+ Model: pBody.model || '',
788
+ FinishReason: tmpChoice.finish_reason || 'unknown',
789
+ PromptTokens: tmpUsage.prompt_tokens || 0,
790
+ CompletionTokens: tmpUsage.completion_tokens || 0,
791
+ TotalTokens: tmpUsage.total_tokens || 0
792
+ };
793
+ }
794
+
795
+ _parseAnthropicChatResponse(pBody)
796
+ {
797
+ let tmpContent = '';
798
+
799
+ if (pBody.content && Array.isArray(pBody.content))
800
+ {
801
+ for (let i = 0; i < pBody.content.length; i++)
802
+ {
803
+ if (pBody.content[i].type === 'text')
804
+ {
805
+ tmpContent += pBody.content[i].text;
806
+ }
807
+ }
808
+ }
809
+
810
+ let tmpUsage = pBody.usage || {};
811
+
812
+ return {
813
+ Content: tmpContent,
814
+ Model: pBody.model || '',
815
+ FinishReason: pBody.stop_reason || 'unknown',
816
+ PromptTokens: tmpUsage.input_tokens || 0,
817
+ CompletionTokens: tmpUsage.output_tokens || 0,
818
+ TotalTokens: (tmpUsage.input_tokens || 0) + (tmpUsage.output_tokens || 0)
819
+ };
820
+ }
821
+
822
+ _parseOllamaChatResponse(pBody)
823
+ {
824
+ let tmpMessage = pBody.message || {};
825
+
826
+ return {
827
+ Content: tmpMessage.content || '',
828
+ Model: pBody.model || '',
829
+ FinishReason: pBody.done ? 'stop' : 'unknown',
830
+ PromptTokens: pBody.prompt_eval_count || 0,
831
+ CompletionTokens: pBody.eval_count || 0,
832
+ TotalTokens: (pBody.prompt_eval_count || 0) + (pBody.eval_count || 0)
833
+ };
834
+ }
835
+
836
+ /**
837
+ * Parse an embedding response.
838
+ */
839
+ _parseEmbeddingResponse(pResponseBody)
840
+ {
841
+ if (this._Backend === 'ollama')
842
+ {
843
+ let tmpEmbedding = pResponseBody.embedding || [];
844
+ return {
845
+ Embedding: JSON.stringify(tmpEmbedding),
846
+ Dimensions: tmpEmbedding.length,
847
+ Model: pResponseBody.model || ''
848
+ };
849
+ }
850
+
851
+ // OpenAI / openai-compatible
852
+ let tmpData = (pResponseBody.data && pResponseBody.data.length > 0) ? pResponseBody.data[0] : {};
853
+ let tmpEmbedding = tmpData.embedding || [];
854
+
855
+ return {
856
+ Embedding: JSON.stringify(tmpEmbedding),
857
+ Dimensions: tmpEmbedding.length,
858
+ Model: pResponseBody.model || ''
859
+ };
860
+ }
861
+
862
+ /**
863
+ * Parse a tool use response into normalized outputs.
864
+ */
865
+ _parseToolUseResponse(pResponseBody)
866
+ {
867
+ if (this._Backend === 'anthropic')
868
+ {
869
+ return this._parseAnthropicToolUseResponse(pResponseBody);
870
+ }
871
+
872
+ // OpenAI / openai-compatible / Ollama
873
+ return this._parseOpenAIToolUseResponse(pResponseBody);
874
+ }
875
+
876
+ _parseOpenAIToolUseResponse(pBody)
877
+ {
878
+ let tmpChoice = (pBody.choices && pBody.choices.length > 0) ? pBody.choices[0] : {};
879
+ let tmpMessage = tmpChoice.message || {};
880
+ let tmpUsage = pBody.usage || {};
881
+ let tmpToolCalls = tmpMessage.tool_calls || [];
882
+
883
+ return {
884
+ Content: tmpMessage.content || '',
885
+ ToolCalls: JSON.stringify(tmpToolCalls),
886
+ ToolCallCount: tmpToolCalls.length,
887
+ Model: pBody.model || '',
888
+ FinishReason: tmpChoice.finish_reason || 'unknown',
889
+ PromptTokens: tmpUsage.prompt_tokens || 0,
890
+ CompletionTokens: tmpUsage.completion_tokens || 0,
891
+ TotalTokens: tmpUsage.total_tokens || 0
892
+ };
893
+ }
894
+
895
+ _parseAnthropicToolUseResponse(pBody)
896
+ {
897
+ let tmpContent = '';
898
+ let tmpToolCalls = [];
899
+
900
+ if (pBody.content && Array.isArray(pBody.content))
901
+ {
902
+ for (let i = 0; i < pBody.content.length; i++)
903
+ {
904
+ if (pBody.content[i].type === 'text')
905
+ {
906
+ tmpContent += pBody.content[i].text;
907
+ }
908
+ else if (pBody.content[i].type === 'tool_use')
909
+ {
910
+ // Normalize to OpenAI-style tool_calls format for consistency
911
+ tmpToolCalls.push({
912
+ id: pBody.content[i].id,
913
+ type: 'function',
914
+ function: {
915
+ name: pBody.content[i].name,
916
+ arguments: JSON.stringify(pBody.content[i].input)
917
+ }
918
+ });
919
+ }
920
+ }
921
+ }
922
+
923
+ let tmpUsage = pBody.usage || {};
924
+
925
+ return {
926
+ Content: tmpContent,
927
+ ToolCalls: JSON.stringify(tmpToolCalls),
928
+ ToolCallCount: tmpToolCalls.length,
929
+ Model: pBody.model || '',
930
+ FinishReason: pBody.stop_reason || 'unknown',
931
+ PromptTokens: tmpUsage.input_tokens || 0,
932
+ CompletionTokens: tmpUsage.output_tokens || 0,
933
+ TotalTokens: (tmpUsage.input_tokens || 0) + (tmpUsage.output_tokens || 0)
934
+ };
935
+ }
936
+
937
+ // ── Utilities ───────────────────────────────────────────────
938
+
939
+ /**
940
+ * Safely parse a JSON string, returning a fallback on failure.
941
+ */
942
+ _safeParseJSON(pString, pFallback)
943
+ {
944
+ if (!pString || typeof pString !== 'string')
945
+ {
946
+ return pFallback;
947
+ }
948
+
949
+ try
950
+ {
951
+ return JSON.parse(pString);
952
+ }
953
+ catch (pError)
954
+ {
955
+ return pFallback;
956
+ }
957
+ }
958
+
959
+ shutdown(fCallback)
960
+ {
961
+ console.log(`[LLM] Provider "${this.Name}" shutting down (backend=${this._Backend}).`);
962
+ return fCallback(null);
963
+ }
964
+ }
965
+
966
+ module.exports = UltravisorBeaconProviderLLM;