@peopl-health/nexus 4.7.0-dev.451 → 5.0.0-dev.453

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.
@@ -5,6 +5,7 @@ const { withTracing } = require('../../utils/tracingDecorator');
5
5
  const { logger } = require('../../utils/logger');
6
6
 
7
7
  const { getRecordByFilter } = require('../../services/airtableService.js');
8
+ const { withThreadRecovery } = require('./threadRecoveryHelper');
8
9
 
9
10
  function getCurRow(baseID, code) {
10
11
  if (code.endsWith('@g.us')) {
@@ -28,7 +29,11 @@ const runAssistantAndWait = async ({ thread, assistant, message = null, runConfi
28
29
  const { polling, tools: configTools, ...config } = runConfig;
29
30
  const tools = assistant.getToolSchemas?.() || configTools || [];
30
31
 
31
- return provider.executeRun({ thread, message, assistant, tools, config, polling });
32
+ return withThreadRecovery(
33
+ (currentThread = thread) => provider.executeRun({ thread: currentThread, message, assistant, tools, config, polling }),
34
+ thread,
35
+ variant
36
+ );
32
37
  };
33
38
 
34
39
  const runAssistantWithRetries = async (thread, assistant, runConfig, patientReply = null) => {
@@ -0,0 +1,50 @@
1
+ const { createLLMProvider } = require('../providers/createLLMProvider');
2
+ const { logger } = require('../../utils/logger');
3
+
4
+ const { Thread } = require('../../models/threadModel');
5
+
6
+ const THREAD_ERROR_CODES = ['thread_not_found', 'invalid_thread_id'];
7
+ const THREAD_ERROR_MESSAGES = ['No thread found', 'thread does not exist', 'Invalid thread'];
8
+
9
+ const isThreadNotFoundError = (error) =>
10
+ error?.status === 404 ||
11
+ THREAD_ERROR_CODES.includes(error?.code) ||
12
+ THREAD_ERROR_MESSAGES.some(msg => error?.message?.includes(msg));
13
+
14
+ const recreateThread = async (thread, variant) => {
15
+ const provider = createLLMProvider({ variant });
16
+ const newConversation = await provider.createConversation({
17
+ metadata: { code: thread.code }
18
+ });
19
+
20
+ await Thread.updateOne(
21
+ { code: thread.code },
22
+ { $set: { conversation_id: newConversation.id } }
23
+ );
24
+
25
+ logger.info('[threadRecovery] Recreated', {
26
+ oldId: thread.conversation_id,
27
+ newId: newConversation.id,
28
+ code: thread.code
29
+ });
30
+ return Thread.findOne({ code: thread.code });
31
+ };
32
+
33
+ const withThreadRecovery = async (operation, thread, variant) => {
34
+ try {
35
+ return await operation();
36
+ } catch (error) {
37
+ if (isThreadNotFoundError(error) && thread) {
38
+ logger.warn('[threadRecovery] Thread not found, recreating', { code: thread.code });
39
+ const recoveredThread = await recreateThread(thread, variant);
40
+ return operation(recoveredThread);
41
+ }
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ module.exports = {
47
+ isThreadNotFoundError,
48
+ recreateThread,
49
+ withThreadRecovery
50
+ };
@@ -15,6 +15,7 @@ const { BaseAssistant } = require('./assistants/BaseAssistant');
15
15
  const { AssistantProcessor } = require('./AssistantProcessor');
16
16
 
17
17
  const { getCurRow, runAssistantAndWait, runAssistantWithRetries } = require('./helpers/assistantHelper');
18
+ const { withThreadRecovery } = require('./helpers/threadRecoveryHelper');
18
19
  const { analyzeImage, buildPrompt } = require('./helpers/llmsHelper');
19
20
 
20
21
  const { getProviderVariant, getAnthropicClient, setOpenAIProvider, getOpenAIProvider, requireOpenAIProvider, configureOpenAIProvider } = require('./config/llmConfig');
@@ -46,6 +47,7 @@ module.exports = {
46
47
  getCurRow,
47
48
  runAssistantAndWait,
48
49
  runAssistantWithRetries,
50
+ withThreadRecovery,
49
51
  analyzeImage,
50
52
  buildPrompt,
51
53
  getProviderVariant,
@@ -160,6 +160,14 @@ class BaseLLMProvider {
160
160
  throw new Error(`${this.constructor.name} must implement _invokeModel()`);
161
161
  }
162
162
 
163
+ async createConversation() {
164
+ throw new Error(`createConversation is not supported by ${this.constructor.name}`);
165
+ }
166
+
167
+ async listMessages() {
168
+ throw new Error(`listMessages is not supported by ${this.constructor.name}`);
169
+ }
170
+
163
171
  async transcribeAudio() {
164
172
  throw new Error(`transcribeAudio is not supported by ${this.constructor.name}`);
165
173
  }
@@ -412,6 +420,13 @@ class BaseLLMProvider {
412
420
  if (segments.length > 1) logger.debug(`[${this.constructor.name}] Concatenated transcript detected, using last segment`, { segmentCount: segments.length });
413
421
  return segments[segments.length - 1].trim();
414
422
  }
423
+
424
+ _ensureId(value) {
425
+ if (!value) throw new Error('Identifier value is required');
426
+ if (typeof value === 'string') return value;
427
+ if (value?.id) return value.id;
428
+ throw new Error('Unable to resolve identifier value');
429
+ }
415
430
  }
416
431
 
417
432
  module.exports = {
@@ -10,6 +10,9 @@ const { logger } = require('../../utils/logger');
10
10
  const { BaseLLMProvider } = require('./BaseLLMProvider');
11
11
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
12
12
 
13
+ const CONVERSATION_PREFIX = 'conv_';
14
+ const MAX_ITEMS_ON_CREATE = 20;
15
+ const MAX_ITEMS_PER_BATCH = 20;
13
16
  const DEFAULT_MAX_FUNCTION_ROUNDS = 5;
14
17
 
15
18
  class OpenAIResponsesProvider extends BaseLLMProvider {
@@ -43,12 +46,76 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
43
46
  this.supportsPromptRegistry = true;
44
47
 
45
48
  this.responses = this.client.responses;
49
+ this.conversations = this.client.conversations;
46
50
  }
47
51
 
48
52
  _operatorInstructionTurn(additionalInstructions) {
49
53
  return { role: 'developer', content: `[Instrucción del operador para tu próxima respuesta al paciente]: ${additionalInstructions}` };
50
54
  }
51
55
 
56
+ async createConversation({ metadata, messages = [], toolResources } = {}) {
57
+ const maxHistoricalMessages = this.conversationManager.maxHistoricalMessages;
58
+ const capped = messages.length > maxHistoricalMessages;
59
+ const messagesToProcess = capped ? messages.slice(-maxHistoricalMessages) : messages;
60
+ if (capped) {
61
+ logger.warn(`[createConversation] Capped ${messages.length} → ${maxHistoricalMessages} messages`);
62
+ }
63
+
64
+ const allItems = this._convertItemsToApiFormat(messagesToProcess);
65
+ const initialItems = allItems.slice(0, MAX_ITEMS_ON_CREATE);
66
+ const remainingItems = allItems.slice(MAX_ITEMS_ON_CREATE);
67
+
68
+ if (remainingItems.length) {
69
+ logger.info(`[createConversation] Batching: ${initialItems.length} initial + ${remainingItems.length} remaining`);
70
+ }
71
+
72
+ const conversation = await this._createConversationWithItems(metadata, initialItems, toolResources);
73
+
74
+ if (remainingItems.length) {
75
+ try {
76
+ await this._addItemsInBatches(conversation.id, remainingItems);
77
+ } catch (error) {
78
+ logger.error('[createConversation] Failed to add remaining messages', { error: error.message });
79
+ }
80
+ }
81
+
82
+ return conversation;
83
+ }
84
+
85
+ async _createConversationWithItems(metadata, items, toolResources) {
86
+ const payload = {
87
+ metadata,
88
+ items: items.length ? items : undefined,
89
+ tool_resources: toolResources
90
+ };
91
+ const { result } = await retryWithBackoff(
92
+ () => this.conversations.create(payload),
93
+ { providerName: this.constructor.name }
94
+ );
95
+ return result;
96
+ }
97
+
98
+ async _addItemsInBatches(threadId, items, batchSize = MAX_ITEMS_PER_BATCH) {
99
+ if (!items?.length) return;
100
+
101
+ const id = this._ensurethreadId(threadId);
102
+ for (let i = 0; i < items.length; i += batchSize) {
103
+ const batch = this._convertItemsToApiFormat(items.slice(i, i + batchSize));
104
+ await retryWithBackoff(
105
+ () => this.conversations.items.create(id, { items: batch }),
106
+ { providerName: this.constructor.name }
107
+ );
108
+ }
109
+ logger.info(`[_addItemsInBatches] Added ${items.length} messages in ${Math.ceil(items.length / batchSize)} batches`);
110
+ }
111
+
112
+ async listMessages({ threadId, order = 'desc', limit } = {}) {
113
+ return this.client.conversations.items.list(
114
+ this._ensurethreadId(threadId),
115
+ { order, limit }
116
+ );
117
+ }
118
+
52
119
  async _invokeModel({
53
120
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
54
121
  modelConfig, metadata = {}, assistant, phiProcessor = null,
@@ -170,6 +237,15 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
170
237
  file, language, response_format: responseFormat, temperature, prompt,
171
238
  });
172
239
  }
240
+
241
+ _ensurethreadId(value) {
242
+ const id = this._ensureId(value);
243
+ if (!id.startsWith(CONVERSATION_PREFIX)) {
244
+ throw new Error(`Expected conversation id to start with '${CONVERSATION_PREFIX}'`);
245
+ }
246
+ return id;
247
+ }
248
+
173
249
  }
174
250
 
175
251
  module.exports = {
@@ -1,11 +1,6 @@
1
1
  const { OpenAI } = require('openai');
2
2
 
3
- const { retryWithBackoff } = require('../../utils/retryUtils');
4
- const { withTiming } = require('../../utils/tracingDecorator');
5
- const { safeParse } = require('../../utils/jsonUtils');
6
-
7
3
  const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
8
- const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
9
4
 
10
5
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
11
6
  const DEFAULT_MODEL_VENDOR = 'openai';
@@ -35,146 +30,9 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
35
30
  this.transcriptionClient = null;
36
31
  }
37
32
 
38
- async _invokeModel({
39
- instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
40
- modelConfig, assistant, phiProcessor = null, trace = null
41
- }, options = {}) {
42
- const executeTools = options.toolExecutor || handleFunctionCalls;
43
- let totalRetries = 0;
44
- const allToolsExecuted = [];
45
- const allToolCalls = [];
46
- const accumulatedUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
47
- const addUsage = (usage) => {
48
- if (!usage) return;
49
- accumulatedUsage.input_tokens += usage.prompt_tokens || 0;
50
- accumulatedUsage.output_tokens += usage.completion_tokens || 0;
51
- accumulatedUsage.total_tokens += usage.total_tokens || 0;
52
- };
53
-
54
- const messages = this._toChatMessages(instructions, input);
55
- const apiCallConfig = { ...this._mapModelConfig(modelConfig) };
56
- if (toolSchemas.length > 0) {
57
- apiCallConfig.tools = toolSchemas;
58
- apiCallConfig.tool_choice = toolChoice;
59
- }
60
-
61
- const makeAPICall = (msgs) => retryWithBackoff(
62
- () => this.client.chat.completions.create({ messages: msgs, ...apiCallConfig }),
63
- { providerName: this.constructor.name }
64
- );
65
-
66
- const { result: { result: response, retries }, durationMs } = await withTiming(() => makeAPICall(messages));
67
- totalRetries += retries;
68
- addUsage(response.usage);
69
- let producingIteration = trace?.addIteration({ response, durationMs, apiCallConfig }) || null;
70
- let finalResponse = response;
71
- const currentMessages = [...messages];
72
-
73
- if (assistant && toolSchemas.length > 0) {
74
- apiCallConfig.tool_choice = followUpToolChoice;
75
-
76
- for (let round = 1; round <= this.maxFunctionRounds; round++) {
77
- const message = finalResponse.choices?.[0]?.message;
78
- const toolCalls = message?.tool_calls || [];
79
- if (!toolCalls.length) break;
80
-
81
- const functionCalls = toolCalls.map(tc => ({
82
- name: tc.function.name,
83
- arguments: tc.function.arguments,
84
- call_id: tc.id,
85
- }));
86
- allToolCalls.push(...toolCalls.map(tc => ({ name: tc.function.name, arguments: safeParse(tc.function.arguments), call_id: tc.id })));
87
- const { outputs, toolsExecuted } = await executeTools(functionCalls, assistant, phiProcessor, producingIteration);
88
-
89
- currentMessages.push(
90
- message,
91
- ...outputs.map(o => ({ role: 'tool', tool_call_id: o.call_id, content: this._toToolContent(o.output) }))
92
- );
93
- allToolsExecuted.push(...toolsExecuted);
94
-
95
- const { result: { result: followUp, retries: followUpRetries }, durationMs: followUpDurationMs } = await withTiming(() => makeAPICall(currentMessages));
96
- totalRetries += followUpRetries;
97
- addUsage(followUp.usage);
98
- producingIteration = trace?.addIteration({ response: followUp, durationMs: followUpDurationMs, apiCallConfig }) || null;
99
- finalResponse = followUp;
100
- }
101
- }
102
-
103
- return { response: this._normalizeResponse(finalResponse), toolCalls: allToolCalls, toolsExecuted: allToolsExecuted, retries: totalRetries, usage: accumulatedUsage };
104
- }
105
-
106
- _toChatMessages(instructions, input) {
107
- const systemParts = [];
108
- if (instructions) systemParts.push(instructions);
109
-
110
- const outputsByCallId = new Map();
111
- for (const item of input || []) {
112
- if (item.type === 'function_call_output') outputsByCallId.set(item.call_id, item.output);
113
- }
114
-
115
- const messages = [];
116
- for (const item of input || []) {
117
- const type = item.type || 'message';
118
- if (type === 'function_call') {
119
- if (!outputsByCallId.has(item.call_id)) continue;
120
- messages.push({
121
- role: 'assistant',
122
- content: null,
123
- tool_calls: [{ id: item.call_id, type: 'function', function: { name: item.name, arguments: item.arguments || '{}' } }],
124
- });
125
- messages.push({ role: 'tool', tool_call_id: item.call_id, content: this._toToolContent(outputsByCallId.get(item.call_id)) });
126
- continue;
127
- }
128
- if (type === 'function_call_output') continue;
129
- const content = item.content;
130
- if (item.role === 'developer' || item.role === 'system') {
131
- if (content) systemParts.push(content);
132
- continue;
133
- }
134
- messages.push({ role: item.role === 'assistant' ? 'assistant' : 'user', content });
135
- }
136
-
137
- const system = systemParts.join('\n\n');
138
- return system ? [{ role: 'system', content: system }, ...messages] : messages;
139
- }
140
-
141
- _toToolContent(output) {
142
- return typeof output === 'string' ? output : JSON.stringify(output ?? '');
143
- }
144
-
145
- _normalizeResponse(response) {
146
- const choice = response.choices?.[0];
147
- const outputText = (choice?.message?.content || '').trim();
148
- const output = outputText
149
- ? [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: outputText }] }]
150
- : [];
151
- const usage = response.usage
152
- ? {
153
- input_tokens: response.usage.prompt_tokens || 0,
154
- output_tokens: response.usage.completion_tokens || 0,
155
- total_tokens: response.usage.total_tokens || 0,
156
- }
157
- : undefined;
158
-
159
- return {
160
- id: response.id,
161
- object: 'response',
162
- model: response.model,
163
- status: choice?.finish_reason === 'content_filter' ? 'refused' : 'completed',
164
- stop_reason: choice?.finish_reason,
165
- output,
166
- output_text: outputText,
167
- usage,
168
- raw: response,
169
- };
170
- }
171
-
172
33
  _mapModelConfig(modelConfig) {
173
- const mapped = { ...(modelConfig || {}) };
174
- if (mapped.max_tokens == null && mapped.max_output_tokens != null) mapped.max_tokens = mapped.max_output_tokens;
34
+ const mapped = { ...modelConfig };
175
35
  delete mapped.store;
176
- delete mapped.text;
177
- delete mapped.max_output_tokens;
178
36
  mapped.model = this._toOpenRouterModel(mapped.model || this.defaults.responseModel);
179
37
  return mapped;
180
38
  }
@@ -196,6 +54,14 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
196
54
  file, language, response_format: responseFormat, temperature, prompt,
197
55
  });
198
56
  }
57
+
58
+ async createConversation() {
59
+ throw new Error('createConversation is not supported by OpenRouterProvider');
60
+ }
61
+
62
+ async listMessages() {
63
+ throw new Error('listMessages is not supported by OpenRouterProvider');
64
+ }
199
65
  }
200
66
 
201
67
  module.exports = {
@@ -14,7 +14,7 @@ const { fetchConversationData, processConversations, startConversation } = requi
14
14
  const { getMessages, countMessages, aggregateMessages } = require('../services/messageService');
15
15
 
16
16
  const { sendMessage } = require('../core/NexusMessaging');
17
- const { getProviderVariant } = require('../clinical');
17
+ const { withThreadRecovery, getProviderVariant, getOpenAIProvider } = require('../clinical');
18
18
 
19
19
  const Message = mongoose.models.Message;
20
20
 
@@ -470,45 +470,65 @@ const markMessagesAsReadController = async (req, res) => {
470
470
  const getOpenAIThreadMessagesController = async (req, res) => {
471
471
  try {
472
472
  const { code } = req.params;
473
- const { order = 'desc' } = req.query;
474
-
473
+ const { limit = 50, order = 'desc', runId } = req.query;
474
+ const variant = getProviderVariant();
475
+
475
476
  if (!code) {
476
477
  return res.status(400).json({
477
478
  success: false,
478
479
  error: 'Phone number is required'
479
480
  });
480
481
  }
481
-
482
- const thread = await Thread.findOne({ code: code }).sort({ createdAt: -1 });
482
+
483
+ let thread = await Thread.findOne({ code: code }).sort({ createdAt: -1 });
483
484
  if (!thread) {
484
485
  return res.status(404).json({
485
486
  success: false,
486
- error: 'No active thread found for this phone number'
487
+ error: 'No active OpenAI thread found for this phone number'
487
488
  });
488
489
  }
489
-
490
- const parsedLimit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 100);
491
- const messages = await getMessages(
492
- { numero: code, group_id: null },
493
- { sort: { createdAt: order === 'asc' ? 1 : -1 }, limit: parsedLimit }
490
+
491
+ const provider = getOpenAIProvider({ instantiate: true, variant });
492
+ if (!provider) throw new Error('OpenAI provider not initialized');
493
+
494
+ const queryParams = {
495
+ threadId: thread.conversation_id,
496
+ order,
497
+ limit: parseInt(limit),
498
+ ...(runId && { runId })
499
+ };
500
+ let threadRecreated = false;
501
+
502
+ const messages = await withThreadRecovery(
503
+ async (currentThread = thread) => {
504
+ if (currentThread !== thread) {
505
+ thread = currentThread;
506
+ queryParams.threadId = currentThread.getConversationId();
507
+ threadRecreated = true;
508
+ }
509
+ return await provider.listMessages(queryParams);
510
+ },
511
+ thread,
512
+ variant
494
513
  );
495
- messages.forEach(msg => { msg.body = msg.plainBody || msg.body; });
496
-
514
+
497
515
  res.status(200).json({
498
516
  success: true,
499
- variant: getProviderVariant(),
517
+ variant,
500
518
  phoneNumber: code,
501
519
  code,
502
520
  threadId: thread.conversation_id,
503
521
  assistantId: thread.assistant_id,
504
- messages,
505
- pagination: { limit: parsedLimit, order }
522
+ messages: messages.data || messages,
523
+ hasMore: messages.has_more || false,
524
+ threadRecreated,
525
+ pagination: { limit: parseInt(limit), order }
506
526
  });
507
527
  } catch (error) {
508
- logger.error('Error fetching thread messages:', { error: error.message });
509
- res.status(500).json({
510
- success: false,
511
- error: error.message || 'Failed to fetch thread messages'
528
+ logger.error('Error fetching OpenAI thread messages:', { error: error.message });
529
+ res.status(500).json({
530
+ success: false,
531
+ error: error.message || 'Failed to fetch OpenAI thread messages'
512
532
  });
513
533
  }
514
534
  };
package/lib/index.d.ts CHANGED
@@ -273,6 +273,8 @@ declare module '@peopl-health/nexus' {
273
273
  constructor(options?: { apiKey?: string; organization?: string; client?: any; defaultModels?: Record<string, string>; conversationManager?: any });
274
274
  getVariant(): string;
275
275
  getClient(): any;
276
+ createConversation(options?: any): Promise<any>;
277
+ listMessages(options?: any): Promise<any>;
276
278
  executeRun(options: { thread: any; assistant: any; message?: string; tools?: any[]; config?: any }): Promise<any>;
277
279
  runConversation(config?: any): Promise<any>;
278
280
  transcribeAudio(options?: any): Promise<any>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "4.7.0-dev.451",
3
+ "version": "5.0.0-dev.453",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",