@peopl-health/nexus 4.7.0-dev.450 → 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,
@@ -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
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "4.7.0-dev.450",
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",