@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
|
|
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
|
+
};
|
package/lib/clinical/index.js
CHANGED
|
@@ -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 = { ...
|
|
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
|
-
|
|
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
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
514
|
+
|
|
497
515
|
res.status(200).json({
|
|
498
516
|
success: true,
|
|
499
|
-
variant
|
|
517
|
+
variant,
|
|
500
518
|
phoneNumber: code,
|
|
501
519
|
code,
|
|
502
520
|
threadId: thread.conversation_id,
|
|
503
521
|
assistantId: thread.assistant_id,
|
|
504
|
-
messages,
|
|
505
|
-
|
|
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
|
};
|