@peopl-health/nexus 4.5.27 → 4.5.29

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/lib/index.js CHANGED
@@ -4,6 +4,7 @@ const { createStorage } = require('./storage/registry');
4
4
  const { MessageParser } = require('./core/MessageParser');
5
5
  const { createLLMProvider } = require('./providers/createLLMProvider');
6
6
  const { OpenAIResponsesProvider } = require('./providers/OpenAIResponsesProvider');
7
+ const { OpenRouterProvider } = require('./providers/OpenRouterProvider');
7
8
  const { logger } = require('./utils/logger');
8
9
  const runtimeConfig = require('./config/runtimeConfig');
9
10
  const llmConfigModule = require('./config/llmConfig');
@@ -197,6 +198,7 @@ module.exports = {
197
198
  MessageParser,
198
199
  createLLMProvider,
199
200
  OpenAIResponsesProvider,
201
+ OpenRouterProvider,
200
202
  BaseAssistant,
201
203
  registerAssistant,
202
204
  overrideGetAssistantById,
@@ -0,0 +1,275 @@
1
+ const runtimeConfig = require('../config/runtimeConfig');
2
+ const { logger } = require('../utils/logger');
3
+ const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
4
+ const { isJsonBlob } = require('../utils/formatUtils');
5
+
6
+ const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
7
+
8
+ const { getMessages } = require('../services/messageService');
9
+
10
+ const { logBugReportToAirtable } = require('../controllers/bugReportController');
11
+
12
+ const DEFAULT_MAX_CONVERSATION_RETRIES = 3;
13
+
14
+ class BaseLLMProvider {
15
+ constructor(options = {}) {
16
+ if (new.target === BaseLLMProvider) {
17
+ throw new Error('BaseLLMProvider is abstract and cannot be instantiated directly');
18
+ }
19
+ const { conversationManager, sessionManager, maxConversationRetries } = options;
20
+ this.client = null;
21
+ this.variant = null;
22
+ this.conversationManager = conversationManager || new DefaultMemoryManager();
23
+ this.sessionManager = sessionManager || null;
24
+ const retries = parseInt(maxConversationRetries, 10);
25
+ this.maxConversationRetries = retries > 0 ? retries : DEFAULT_MAX_CONVERSATION_RETRIES;
26
+ }
27
+
28
+ getVariant() { return this.variant; }
29
+ getClient() { return this.client; }
30
+
31
+ async _executeConversation() {
32
+ throw new Error(`${this.constructor.name} must implement _executeConversation()`);
33
+ }
34
+
35
+ async createConversation() {
36
+ throw new Error(`createConversation is not supported by ${this.constructor.name}`);
37
+ }
38
+
39
+ async addMessage() {
40
+ throw new Error(`addMessage is not supported by ${this.constructor.name}`);
41
+ }
42
+
43
+ async listMessages() {
44
+ throw new Error(`listMessages is not supported by ${this.constructor.name}`);
45
+ }
46
+
47
+ async transcribeAudio() {
48
+ throw new Error(`transcribeAudio is not supported by ${this.constructor.name}`);
49
+ }
50
+
51
+ async executeRun({ thread, assistant, message = null, tools = [], config = {} }) {
52
+ const { conversationId, assistantId } = this._normalizeThread(thread);
53
+ const promptVersion = thread?.version || null;
54
+ const presetId = thread?.preset_id || null;
55
+ const presetVersion = thread?.preset_version || null;
56
+
57
+ logger.info('[executeRun] Starting', { conversationId, assistantId, promptVersion });
58
+
59
+ try {
60
+ const context = await this.conversationManager.buildContext({
61
+ thread,
62
+ message,
63
+ config: {
64
+ ...config,
65
+ threadId: conversationId,
66
+ assistantId,
67
+ }
68
+ });
69
+
70
+ const [lastMessage] = await getMessages({ numero: thread.code }, { sort: { createdAt: -1 }, limit: 1 });
71
+ const phiProcessor = config.phiProcessor;
72
+ const maskedCode = phiProcessor ? phiProcessor.hashCode(thread.code) : thread.code;
73
+ const metadata = {
74
+ numero: maskedCode,
75
+ message_id: message?.message_id || lastMessage?.message_id || null
76
+ };
77
+
78
+ logger.info('[executeRun] Context built', { conversationId, messageCount: context?.length || 0 });
79
+
80
+ const override = config.clinicalData;
81
+ const hasOverride = override && typeof override === 'object' && Object.keys(override).length > 0;
82
+ const clinicalData = hasOverride ? override : await this.conversationManager.getClinicalData(thread.code);
83
+ const promptVariables = {
84
+ clinical_context: clinicalData?.clinicalContext ?? '',
85
+ last_symptoms: clinicalData?.lastSymptoms ?? '',
86
+ current_date: getCurrentMexicoDateTime(),
87
+ patient_memories: clinicalData?.patientMemories ?? '',
88
+ conversation_summaries: clinicalData?.conversationSummaries ?? '',
89
+ };
90
+
91
+ const result = await this.runConversation({
92
+ threadId: conversationId,
93
+ assistantId,
94
+ presetId,
95
+ presetVersion,
96
+ tools,
97
+ context,
98
+ promptVariables,
99
+ promptVersion,
100
+ assistant,
101
+ metadata,
102
+ ...config
103
+ });
104
+
105
+ await this.conversationManager.processResponse(result, thread, config);
106
+ this.sessionManager?.recordActivity(thread.code);
107
+
108
+ const completed = result.status === 'completed';
109
+ const output = result.output_text || this._extractMessageOutput(result);
110
+ const toolsExecuted = result.tools_executed?.length || 0;
111
+
112
+ logger.info('[executeRun] Complete', {
113
+ runId: result.id,
114
+ completed,
115
+ outputLength: output?.length || 0,
116
+ toolsExecuted
117
+ });
118
+
119
+ return { run: result, completed, output, tools_executed: result.tools_executed || [], retries: result.retries || 0 };
120
+ } catch (error) {
121
+ logger.error('[executeRun] Failed', { conversationId, assistantId, error: error.message });
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ async runConversation(config = {}) {
127
+ const { threadId, assistantId } = config;
128
+ const maxRetries = this.maxConversationRetries;
129
+
130
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
131
+ try {
132
+ logger.info('[runConversation] Attempt', { attempt, maxRetries, threadId, assistantId });
133
+
134
+ const result = await this._executeConversation(config);
135
+ const extractedOutput = this._extractMessageOutput(result);
136
+
137
+ if (extractedOutput?.trim()) {
138
+ result.output_text = extractedOutput;
139
+ logger.info('[runConversation] Success', {
140
+ attempt,
141
+ outputLength: extractedOutput.length,
142
+ toolsExecuted: result.tools_executed?.length || 0
143
+ });
144
+ return result;
145
+ }
146
+
147
+ logger.warn('[runConversation] Empty output', { attempt });
148
+ if (attempt === maxRetries) {
149
+ throw new Error(`Conversation failed after ${attempt} attempts - no valid output`);
150
+ }
151
+ await new Promise(r => setTimeout(r, 500));
152
+ } catch (error) {
153
+ logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
154
+ if (attempt === maxRetries) throw error;
155
+ await new Promise(r => setTimeout(r, 500));
156
+ }
157
+ }
158
+ }
159
+
160
+ _normalizeThread(thread) {
161
+ return {
162
+ conversationId: thread.conversation_id || thread.getConversationId?.(),
163
+ assistantId: thread.prompt_id || thread.getAssistantId?.()
164
+ };
165
+ }
166
+
167
+ _convertItemsToApiFormat(items) {
168
+ return items.map(item => {
169
+ const type = item.type || 'message';
170
+ if (type === 'function_call' || type === 'function_call_output') {
171
+ return { ...item, type };
172
+ }
173
+ return {
174
+ role: item.role || 'user',
175
+ type,
176
+ content: this._normalizeContent(item.content)
177
+ };
178
+ });
179
+ }
180
+
181
+ _normalizeContent(content) {
182
+ if (typeof content === 'string') return content;
183
+ if (Array.isArray(content)) return content;
184
+ if (content?.text) return content.text;
185
+ if (content && typeof content === 'object') return JSON.stringify(content);
186
+ return content || '';
187
+ }
188
+
189
+ _contentPartToText(part) {
190
+ if (part == null || typeof part !== 'object') return '';
191
+ return typeof part.text === 'string' ? part.text : (typeof part.text?.value === 'string' ? part.text.value : '');
192
+ }
193
+
194
+ _messageItemToText(item) {
195
+ if (!item) return '';
196
+ if (Array.isArray(item.content)) {
197
+ return item.content.map(c => this._contentPartToText(c)).filter(Boolean).join('').trim();
198
+ }
199
+ if (typeof item.content === 'string') return item.content.trim();
200
+ return '';
201
+ }
202
+
203
+ _extractMessageOutput(result) {
204
+ if (result == null || typeof result !== 'object') return '';
205
+
206
+ if (result.output && Array.isArray(result.output)) {
207
+ const messageItems = result.output.filter(item => item && item.type === 'message');
208
+ if (messageItems.length > 0) {
209
+ const texts = messageItems.map(item => this._messageItemToText(item)).filter(Boolean);
210
+ const proseTexts = texts.filter(text => !isJsonBlob(text));
211
+ const jsonDiscarded = texts.length - proseTexts.length;
212
+ const keptOne = proseTexts.length > 0 ? 1 : 0;
213
+
214
+ if (messageItems.length > 1 || jsonDiscarded > 0) {
215
+ const hasFunctionCalls = result.output.some(item => item?.type === 'function_call');
216
+ logger.warn(`[${this.constructor.name}] Multiple/structured message items in response; keeping the first natural-language reply (likely OpenAI multi-output bug)`, {
217
+ discarded: messageItems.length - keptOne,
218
+ jsonDiscarded,
219
+ responseId: result.id,
220
+ model: result.model,
221
+ hasFunctionCalls
222
+ });
223
+ logBugReportToAirtable({
224
+ reporter: 'system',
225
+ description: [
226
+ `Responses API (${this.constructor.name}) returned multiple message items in a single response.`,
227
+ 'Suspected upstream bug (model failed to emit stop-of-message token).',
228
+ `Discarded ${messageItems.length - keptOne} extra message(s); kept the first natural-language reply as the canonical reply.`,
229
+ jsonDiscarded > 0 ? `${jsonDiscarded} discarded item(s) were raw JSON/structured output that must never reach the patient.` : null,
230
+ `Response ID: ${result.id || 'unknown'}`,
231
+ `Model: ${result.model || 'unknown'}`,
232
+ `Function calls present: ${hasFunctionCalls ? 'yes' : 'no'}`
233
+ ].filter(Boolean).join('\n'),
234
+ severity: 'medium',
235
+ status: 'Open',
236
+ clasificacion: 'alucinaciones',
237
+ bugType: 'backend',
238
+ owner: ['ariana'],
239
+ request_id: result.id || null,
240
+ server: runtimeConfig.get('SERVICE_NAME') || 'nexus'
241
+ }).catch((err) => logger.warn(`[${this.constructor.name}] Bug report logger failed`, { error: err.message }));
242
+ }
243
+
244
+ if (proseTexts.length > 0) return proseTexts[0];
245
+ }
246
+ }
247
+ if (result.output_text) return this._extractMessageFromConcatenatedOutput(result.output_text);
248
+ return '';
249
+ }
250
+
251
+ _extractMessageFromConcatenatedOutput(outputText) {
252
+ if (!outputText || typeof outputText !== 'string') return '';
253
+ if (!/^\s*assistant\s*\n/i.test(outputText)) return outputText.trim();
254
+
255
+ const segments = outputText
256
+ .split(/\n\s*assistant\s*\n/i)
257
+ .map(s => s.replace(/^assistant\s*\n?/i, '').trim())
258
+ .filter(Boolean);
259
+
260
+ if (segments.length === 0) return '';
261
+ if (segments.length > 1) logger.debug(`[${this.constructor.name}] Concatenated transcript detected, using last segment`, { segmentCount: segments.length });
262
+ return segments[segments.length - 1].trim();
263
+ }
264
+
265
+ _ensureId(value) {
266
+ if (!value) throw new Error('Identifier value is required');
267
+ if (typeof value === 'string') return value;
268
+ if (value?.id) return value.id;
269
+ throw new Error('Unable to resolve identifier value');
270
+ }
271
+ }
272
+
273
+ module.exports = {
274
+ BaseLLMProvider,
275
+ };
@@ -1,18 +1,12 @@
1
1
  const { OpenAI } = require('openai');
2
2
 
3
- const runtimeConfig = require('../config/runtimeConfig');
4
3
  const { retryWithBackoff } = require('../utils/retryUtils');
5
4
  const { logger } = require('../utils/logger');
6
- const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
7
- const { isJsonBlob } = require('../utils/formatUtils');
8
-
9
- const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
10
5
 
11
6
  const { composePrompt, resolveTools } = require('../services/promptComposerService');
12
7
  const { getToolSchemas: getRegistrySchemas } = require('../services/toolRegistryService');
13
- const { getMessages } = require('../services/messageService');
14
8
 
15
- const { logBugReportToAirtable } = require('../controllers/bugReportController');
9
+ const { BaseLLMProvider } = require('./BaseLLMProvider');
16
10
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
17
11
 
18
12
  const CONVERSATION_PREFIX = 'conv_';
@@ -20,19 +14,16 @@ const RESPONSE_PREFIX = 'resp_';
20
14
  const MAX_ITEMS_ON_CREATE = 20;
21
15
  const MAX_ITEMS_PER_BATCH = 20;
22
16
  const DEFAULT_MAX_HISTORICAL_MESSAGES = parseInt(process.env.MAX_HISTORICAL_MESSAGES || '50', 10);
23
- const MAX_CONVERSATION_RETRIES = parseInt(process.env.MAX_CONVERSATION_RETRIES || '3', 10);
24
17
  const MAX_FUNCTION_ROUNDS = parseInt(process.env.MAX_FUNCTION_ROUNDS || '5', 10);
25
- const PROVIDER_NAME = 'OpenAIResponsesProvider';
26
18
 
27
- class OpenAIResponsesProvider {
19
+ class OpenAIResponsesProvider extends BaseLLMProvider {
28
20
  constructor(options = {}) {
21
+ super(options);
29
22
  const {
30
23
  apiKey = process.env.OPENAI_API_KEY,
31
24
  organization,
32
25
  client,
33
26
  defaultModels = {},
34
- conversationManager,
35
- sessionManager,
36
27
  } = options;
37
28
 
38
29
  if (!client && !apiKey) {
@@ -51,16 +42,12 @@ class OpenAIResponsesProvider {
51
42
  };
52
43
 
53
44
  this.variant = 'responses';
54
- this.conversationManager = conversationManager || new DefaultMemoryManager();
55
- this.sessionManager = sessionManager || null;
45
+ this.supportsPromptRegistry = true;
56
46
 
57
47
  this.responses = this.client.responses;
58
48
  this.conversations = this.client.conversations;
59
49
  }
60
50
 
61
- getVariant() { return this.variant; }
62
- getClient() { return this.client; }
63
-
64
51
  async createConversation({ metadata, messages = [], toolResources } = {}) {
65
52
  const capped = messages.length > DEFAULT_MAX_HISTORICAL_MESSAGES;
66
53
  const messagesToProcess = capped ? messages.slice(-DEFAULT_MAX_HISTORICAL_MESSAGES) : messages;
@@ -97,7 +84,7 @@ class OpenAIResponsesProvider {
97
84
  };
98
85
  const { result } = await retryWithBackoff(
99
86
  () => this.conversations.create(payload),
100
- { providerName: PROVIDER_NAME }
87
+ { providerName: this.constructor.name }
101
88
  );
102
89
  return result;
103
90
  }
@@ -110,34 +97,12 @@ class OpenAIResponsesProvider {
110
97
  const batch = this._convertItemsToApiFormat(items.slice(i, i + batchSize));
111
98
  await retryWithBackoff(
112
99
  () => this.conversations.items.create(id, { items: batch }),
113
- { providerName: PROVIDER_NAME }
100
+ { providerName: this.constructor.name }
114
101
  );
115
102
  }
116
103
  logger.info(`[_addItemsInBatches] Added ${items.length} messages in ${Math.ceil(items.length / batchSize)} batches`);
117
104
  }
118
105
 
119
- _convertItemsToApiFormat(items) {
120
- return items.map(item => {
121
- const type = item.type || 'message';
122
- if (type === 'function_call' || type === 'function_call_output') {
123
- return { ...item, type };
124
- }
125
- return {
126
- role: item.role || 'user',
127
- type,
128
- content: this._normalizeContent(item.content)
129
- };
130
- });
131
- }
132
-
133
- _normalizeContent(content) {
134
- if (typeof content === 'string') return content;
135
- if (Array.isArray(content)) return content;
136
- if (content?.text) return content.text;
137
- if (content && typeof content === 'object') return JSON.stringify(content);
138
- return content || '';
139
- }
140
-
141
106
  async addMessage({ threadId, messages, role = 'user', content, metadata }) {
142
107
  const id = this._ensurethreadId(threadId);
143
108
  const messagesToAdd = messages || [{ role, content, metadata }];
@@ -156,199 +121,6 @@ class OpenAIResponsesProvider {
156
121
  );
157
122
  }
158
123
 
159
- _normalizeThread(thread) {
160
- return {
161
- conversationId: thread.conversation_id || thread.getConversationId?.(),
162
- assistantId: thread.prompt_id || thread.getAssistantId?.()
163
- };
164
- }
165
-
166
- _contentPartToText(part) {
167
- if (part == null || typeof part !== 'object') return '';
168
- return typeof part.text === 'string' ? part.text : (typeof part.text?.value === 'string' ? part.text.value : '');
169
- }
170
-
171
- _messageItemToText(item) {
172
- if (!item) return '';
173
- if (Array.isArray(item.content)) {
174
- return item.content.map(c => this._contentPartToText(c)).filter(Boolean).join('').trim();
175
- }
176
- if (typeof item.content === 'string') return item.content.trim();
177
- return '';
178
- }
179
-
180
- _extractMessageOutput(result) {
181
- if (result == null || typeof result !== 'object') return '';
182
-
183
- if (result.output && Array.isArray(result.output)) {
184
- const messageItems = result.output.filter(item => item && item.type === 'message');
185
- if (messageItems.length > 0) {
186
- const texts = messageItems.map(item => this._messageItemToText(item)).filter(Boolean);
187
- const proseTexts = texts.filter(text => !isJsonBlob(text));
188
- const jsonDiscarded = texts.length - proseTexts.length;
189
- const keptOne = proseTexts.length > 0 ? 1 : 0;
190
-
191
- if (messageItems.length > 1 || jsonDiscarded > 0) {
192
- const hasFunctionCalls = result.output.some(item => item?.type === 'function_call');
193
- logger.warn('[OpenAIResponsesProvider] Multiple/structured message items in response; keeping the first natural-language reply (likely OpenAI multi-output bug)', {
194
- discarded: messageItems.length - keptOne,
195
- jsonDiscarded,
196
- responseId: result.id,
197
- model: result.model,
198
- hasFunctionCalls
199
- });
200
- logBugReportToAirtable({
201
- reporter: 'system',
202
- description: [
203
- 'OpenAI Responses API returned multiple message items in a single response.',
204
- 'Suspected upstream bug (model failed to emit stop-of-message token).',
205
- `Discarded ${messageItems.length - keptOne} extra message(s); kept the first natural-language reply as the canonical reply.`,
206
- jsonDiscarded > 0 ? `${jsonDiscarded} discarded item(s) were raw JSON/structured output that must never reach the patient.` : null,
207
- `Response ID: ${result.id || 'unknown'}`,
208
- `Model: ${result.model || 'unknown'}`,
209
- `Function calls present: ${hasFunctionCalls ? 'yes' : 'no'}`
210
- ].filter(Boolean).join('\n'),
211
- severity: 'medium',
212
- status: 'Open',
213
- clasificacion: 'alucinaciones',
214
- bugType: 'backend',
215
- owner: ['ariana'],
216
- request_id: result.id || null,
217
- server: runtimeConfig.get('SERVICE_NAME') || 'nexus'
218
- }).catch((err) => logger.warn('[OpenAIResponsesProvider] Bug report logger failed', { error: err.message }));
219
- }
220
-
221
- if (proseTexts.length > 0) return proseTexts[0];
222
- }
223
- }
224
- if (result.output_text) return this._extractMessageFromConcatenatedOutput(result.output_text);
225
- return '';
226
- }
227
-
228
- _extractMessageFromConcatenatedOutput(outputText) {
229
- if (!outputText || typeof outputText !== 'string') return '';
230
- if (!/^\s*assistant\s*\n/i.test(outputText)) return outputText.trim();
231
-
232
- const segments = outputText
233
- .split(/\n\s*assistant\s*\n/i)
234
- .map(s => s.replace(/^assistant\s*\n?/i, '').trim())
235
- .filter(Boolean);
236
-
237
- if (segments.length === 0) return '';
238
- if (segments.length > 1) logger.debug('[OpenAIResponsesProvider] Concatenated transcript detected, using last segment', { segmentCount: segments.length });
239
- return segments[segments.length - 1].trim();
240
- }
241
-
242
- async executeRun({ thread, assistant, message = null, tools = [], config = {} }) {
243
- const { conversationId, assistantId } = this._normalizeThread(thread);
244
- const promptVersion = thread?.version || null;
245
- const presetId = thread?.preset_id || null;
246
- const presetVersion = thread?.preset_version || null;
247
-
248
- logger.info('[executeRun] Starting', { conversationId, assistantId, promptVersion });
249
-
250
- try {
251
- const context = await this.conversationManager.buildContext({
252
- thread,
253
- message,
254
- config: {
255
- ...config,
256
- threadId: conversationId,
257
- assistantId,
258
- }
259
- });
260
-
261
- const [lastMessage] = await getMessages({ numero: thread.code }, { sort: { createdAt: -1 }, limit: 1 });
262
- const phiProcessor = config.phiProcessor;
263
- const maskedCode = phiProcessor ? phiProcessor.hashCode(thread.code) : thread.code;
264
- const metadata = {
265
- numero: maskedCode,
266
- message_id: message?.message_id || lastMessage?.message_id || null
267
- };
268
-
269
- logger.info('[executeRun] Context built', { conversationId, messageCount: context?.length || 0 });
270
-
271
- // config.clinicalData, when given, fully replaces the fetched bundle (all four fields).
272
- // Must be a complete, non-empty object; anything else falls through to the normal fetch.
273
- const override = config.clinicalData;
274
- const hasOverride = override && typeof override === 'object' && Object.keys(override).length > 0;
275
- const clinicalData = hasOverride ? override : await this.conversationManager.getClinicalData(thread.code);
276
- const promptVariables = {
277
- clinical_context: clinicalData?.clinicalContext ?? '',
278
- last_symptoms: clinicalData?.lastSymptoms ?? '',
279
- current_date: getCurrentMexicoDateTime(),
280
- patient_memories: clinicalData?.patientMemories ?? '',
281
- conversation_summaries: clinicalData?.conversationSummaries ?? '',
282
- };
283
-
284
- const result = await this.runConversation({
285
- threadId: conversationId,
286
- assistantId,
287
- presetId,
288
- presetVersion,
289
- tools,
290
- context,
291
- promptVariables,
292
- promptVersion,
293
- assistant,
294
- metadata,
295
- ...config
296
- });
297
-
298
- await this.conversationManager.processResponse(result, thread, config);
299
- this.sessionManager?.recordActivity(thread.code);
300
-
301
- const completed = result.status === 'completed';
302
- const output = result.output_text || this._extractMessageOutput(result);
303
- const toolsExecuted = result.tools_executed?.length || 0;
304
-
305
- logger.info('[executeRun] Complete', {
306
- runId: result.id,
307
- completed,
308
- outputLength: output?.length || 0,
309
- toolsExecuted
310
- });
311
-
312
- return { run: result, completed, output, tools_executed: result.tools_executed || [], retries: result.retries || 0 };
313
- } catch (error) {
314
- logger.error('[executeRun] Failed', { conversationId, assistantId, error: error.message });
315
- throw error;
316
- }
317
- }
318
-
319
- async runConversation(config = {}) {
320
- const { threadId, assistantId } = config;
321
-
322
- for (let attempt = 1; attempt <= MAX_CONVERSATION_RETRIES; attempt++) {
323
- try {
324
- logger.info('[runConversation] Attempt', { attempt, maxRetries: MAX_CONVERSATION_RETRIES, threadId, assistantId });
325
-
326
- const result = await this._executeConversation(config);
327
- const extractedOutput = this._extractMessageOutput(result);
328
-
329
- if (extractedOutput?.trim()) {
330
- result.output_text = extractedOutput;
331
- logger.info('[runConversation] Success', {
332
- attempt,
333
- outputLength: extractedOutput.length,
334
- toolsExecuted: result.tools_executed?.length || 0
335
- });
336
- return result;
337
- }
338
-
339
- logger.warn('[runConversation] Empty output', { attempt });
340
- if (attempt === MAX_CONVERSATION_RETRIES) {
341
- throw new Error(`Conversation failed after ${attempt} attempts - no valid output`);
342
- }
343
- await new Promise(r => setTimeout(r, 500));
344
- } catch (error) {
345
- logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
346
- if (attempt === MAX_CONVERSATION_RETRIES) throw error;
347
- await new Promise(r => setTimeout(r, 500));
348
- }
349
- }
350
- }
351
-
352
124
  async _executeConversation(config = {}) {
353
125
  const {
354
126
  threadId, assistantId, presetId = null, presetVersion = null, additionalMessages = [], context = null,
@@ -439,13 +211,13 @@ class OpenAIResponsesProvider {
439
211
  if (promptVersion) promptConfig.version = String(promptVersion);
440
212
 
441
213
  const apiCallConfig = {
442
- ...modelConfig,
214
+ ...this._mapModelConfig(modelConfig),
443
215
  instructions: instructions || additionalInstructions || devContent || '',
444
216
  metadata,
445
217
  tool_choice: prePromptResult?.toolChoice || toolChoice
446
218
  };
447
219
 
448
- if (!resolvedPresetId) {
220
+ if (!resolvedPresetId && this.supportsPromptRegistry) {
449
221
  apiCallConfig.prompt = promptConfig;
450
222
  }
451
223
  if (activeToolSchemas.length > 0) {
@@ -460,7 +232,7 @@ class OpenAIResponsesProvider {
460
232
 
461
233
  const makeAPICall = (inputData) => retryWithBackoff(
462
234
  () => this.client.responses.create({ input: inputData, ...apiCallConfig }),
463
- { providerName: PROVIDER_NAME }
235
+ { providerName: this.constructor.name }
464
236
  );
465
237
 
466
238
  const { result: response, retries } = await makeAPICall(input);
@@ -471,7 +243,6 @@ class OpenAIResponsesProvider {
471
243
  if (assistant && response.output) {
472
244
  let currentInput = [...input];
473
245
 
474
- // Follow-up calls always use the default tool_choice.
475
246
  apiCallConfig.tool_choice = toolChoice;
476
247
 
477
248
  for (let round = 1; round <= MAX_FUNCTION_ROUNDS; round++) {
@@ -509,6 +280,10 @@ class OpenAIResponsesProvider {
509
280
  return result;
510
281
  }
511
282
 
283
+ _mapModelConfig(modelConfig) {
284
+ return modelConfig;
285
+ }
286
+
512
287
  async transcribeAudio({ file, model, language, responseFormat, temperature, prompt } = {}) {
513
288
  return this.client.audio.transcriptions.create({
514
289
  model: model || this.defaults.transcriptionModel,
@@ -532,13 +307,6 @@ class OpenAIResponsesProvider {
532
307
  return id;
533
308
  }
534
309
 
535
- _ensureId(value) {
536
- if (!value) throw new Error('Identifier value is required');
537
- if (typeof value === 'string') return value;
538
- if (value?.id) return value.id;
539
- throw new Error('Unable to resolve identifier value');
540
- }
541
-
542
310
  async _post(path, body, options = {}) { return this.client.post(path, { ...options, body }); }
543
311
  async _get(path, query, options = {}) { return this.client.get(path, { ...options, query }); }
544
312
  async _delete(path, options = {}) { return this.client.delete(path, options); }
@@ -0,0 +1,67 @@
1
+ const { OpenAI } = require('openai');
2
+
3
+ const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
4
+
5
+ const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
6
+ const DEFAULT_MODEL_VENDOR = 'openai';
7
+
8
+ class OpenRouterProvider extends OpenAIResponsesProvider {
9
+ constructor(options = {}) {
10
+ const {
11
+ apiKey = process.env.OPENROUTER_API_KEY,
12
+ baseURL = OPENROUTER_BASE_URL,
13
+ client,
14
+ } = options;
15
+
16
+ if (!client && !apiKey) {
17
+ throw new Error('OpenRouterProvider requires an API key or a preconfigured client');
18
+ }
19
+
20
+ super({ ...options, client: client || new OpenAI({ apiKey, baseURL }) });
21
+
22
+ this.variant = 'openrouter';
23
+ this.supportsPromptRegistry = false;
24
+ this.transcriptionClient = null;
25
+ }
26
+
27
+ _mapModelConfig(modelConfig) {
28
+ const mapped = { ...modelConfig };
29
+ mapped.model = this._toOpenRouterModel(mapped.model || this.defaults.responseModel);
30
+ return mapped;
31
+ }
32
+
33
+ _toOpenRouterModel(model) {
34
+ if (!model || model.includes('/')) return model;
35
+ return `${DEFAULT_MODEL_VENDOR}/${model}`;
36
+ }
37
+
38
+ async transcribeAudio({ file, model, language, responseFormat, temperature, prompt } = {}) {
39
+ if (!this.transcriptionClient) {
40
+ const apiKey = process.env.OPENAI_API_KEY;
41
+ if (!apiKey) {
42
+ throw new Error('transcribeAudio requires OPENAI_API_KEY; OpenRouter does not offer audio transcription');
43
+ }
44
+ this.transcriptionClient = new OpenAI({ apiKey });
45
+ }
46
+ return this.transcriptionClient.audio.transcriptions.create({
47
+ model: model || this.defaults.transcriptionModel,
48
+ file, language, response_format: responseFormat, temperature, prompt,
49
+ });
50
+ }
51
+
52
+ async createConversation() {
53
+ throw new Error('createConversation is not supported by OpenRouterProvider');
54
+ }
55
+
56
+ async addMessage() {
57
+ throw new Error('addMessage is not supported by OpenRouterProvider');
58
+ }
59
+
60
+ async listMessages() {
61
+ throw new Error('listMessages is not supported by OpenRouterProvider');
62
+ }
63
+ }
64
+
65
+ module.exports = {
66
+ OpenRouterProvider,
67
+ };
@@ -4,9 +4,11 @@ const { EnhancedMemoryManager } = require('../memory/EnhancedMemoryManager');
4
4
  const { SessionManager } = require('../memory/SessionManager');
5
5
 
6
6
  const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
7
+ const { OpenRouterProvider } = require('./OpenRouterProvider');
7
8
 
8
9
  const PROVIDER_VARIANTS = {
9
10
  responses: OpenAIResponsesProvider,
11
+ openrouter: OpenRouterProvider,
10
12
  };
11
13
 
12
14
  function createLLMProvider(config = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "4.5.27",
3
+ "version": "4.5.29",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",