freegate 0.6.23 → 0.6.24
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/config.example.json +4 -0
- package/lib/cache.js +8 -5
- package/lib/clean.js +5 -0
- package/lib/compactor.js +11 -6
- package/lib/diagmonitor.js +92 -0
- package/lib/providers.js +1 -1
- package/package.json +4 -2
- package/providers.json +5 -5
- package/server.js +19 -8
package/config.example.json
CHANGED
package/lib/cache.js
CHANGED
|
@@ -22,7 +22,7 @@ class LRUCache {
|
|
|
22
22
|
if (!skipLoad) this.load();
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
_key(model, messages, temperature) {
|
|
25
|
+
_key(model, messages, temperature, tools) {
|
|
26
26
|
let raw;
|
|
27
27
|
if (this.useNormalize) {
|
|
28
28
|
const norm = normalizeMessages(messages);
|
|
@@ -34,6 +34,9 @@ class LRUCache {
|
|
|
34
34
|
} else {
|
|
35
35
|
raw = `${model}|${JSON.stringify(messages)}|${temperature || 0}`;
|
|
36
36
|
}
|
|
37
|
+
// Инструменты (tools/tool_choice) влияют на ответ (tool_calls vs текст) —
|
|
38
|
+
// без этого tool-запрос попадал на текстовый кэш и агент «останавливался».
|
|
39
|
+
if (tools) raw += `|tools=${JSON.stringify(tools)}`;
|
|
37
40
|
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -77,8 +80,8 @@ if (now - e.created > this.ttl) continue;
|
|
|
77
80
|
} catch {}
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
get(model, messages, temperature) {
|
|
81
|
-
const key = this._key(model, messages, temperature);
|
|
83
|
+
get(model, messages, temperature, tools) {
|
|
84
|
+
const key = this._key(model, messages, temperature, tools);
|
|
82
85
|
const entry = this.cache.get(key);
|
|
83
86
|
|
|
84
87
|
if (!entry) {
|
|
@@ -141,8 +144,8 @@ if (now - e.created > this.ttl) continue;
|
|
|
141
144
|
return { value: entry.value, similarity: Math.round(bestSim * 1000) / 1000 };
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
set(model, messages, temperature, value, providerKey) {
|
|
145
|
-
const key = this._key(model, messages, temperature);
|
|
147
|
+
set(model, messages, temperature, value, providerKey, tools) {
|
|
148
|
+
const key = this._key(model, messages, temperature, tools);
|
|
146
149
|
|
|
147
150
|
// Delete if exists (to update order)
|
|
148
151
|
if (this.cache.has(key)) this.cache.delete(key);
|
package/lib/clean.js
CHANGED
|
@@ -62,6 +62,7 @@ function hasContent(data) {
|
|
|
62
62
|
if (!msg) return false;
|
|
63
63
|
const content = typeof msg.content === 'string' ? msg.content.trim() : '';
|
|
64
64
|
const reasoning = typeof msg.reasoning === 'string' ? msg.reasoning.trim() : '';
|
|
65
|
+
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) return true;
|
|
65
66
|
return content.length > 0 || reasoning.length > 0;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -75,6 +76,10 @@ function isTooShort(data, askedText) {
|
|
|
75
76
|
const msg = data.choices[0]?.message;
|
|
76
77
|
const content = typeof msg?.content === 'string' ? msg.content.trim() : '';
|
|
77
78
|
const reasoning = typeof msg?.reasoning === 'string' ? msg.reasoning.trim() : '';
|
|
79
|
+
// Ответ через tool-вызов (агент вызвал инструмент) НЕ пустой — это осмысленный
|
|
80
|
+
// ход, а не «мусор». Без этой проверки прокси считал tool_calls-ответ пустым,
|
|
81
|
+
// отбрасывал его и агент «останавливался», не получив инструмента.
|
|
82
|
+
if (Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0) return false;
|
|
78
83
|
const ask = typeof askedText === 'string' ? askedText.trim().length : 0;
|
|
79
84
|
const threshold = ask > 0 && ask < 40 ? Math.max(1, Math.min(MIN_ANSWER_LEN, Math.ceil(ask / 8))) : MIN_ANSWER_LEN;
|
|
80
85
|
return (content + reasoning).length < threshold;
|
package/lib/compactor.js
CHANGED
|
@@ -17,12 +17,17 @@ const KEEP_RECENT_TOKENS = 30000; // tokens — recent messages kept untouched (
|
|
|
17
17
|
const MAX_COMPACT_THRESHOLD = 100000; // est tokens — cap for window-aware thresholds (≈200k real,
|
|
18
18
|
// sits safely inside even 512k/1M windows without compacting too early)
|
|
19
19
|
const SUMMARY_MAX_TOKENS = 2000; // tokens — summary length cap (detailed enough to not lose the work)
|
|
20
|
-
const CHARS_PER_TOKEN =
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
20
|
+
const CHARS_PER_TOKEN = 3.0; // heuristic chars→tokens. БОЛЬШЕ делитель = МЕНЬШЕ оценка.
|
|
21
|
+
// Реальная плотность смешанного кода/текста ~4-6 chars на
|
|
22
|
+
// токен (не 1.5-2). chars/3.0 приближает оценку к реальным
|
|
23
|
+
// токенам, поэтому порог 60000 достигается только на реально
|
|
24
|
+
// больших диалогах (10+ компактированных страниц), а не на
|
|
25
|
+
// каждом рабочем чате с агентами.
|
|
26
|
+
// Раньше (1.5) оценка завышалась в ~4-6 раз → компакции шли
|
|
27
|
+
// на 86% запросов. Каждая компакция — отдельный LLM-вызов
|
|
28
|
+
// перед ответом = агент «замирает» / виснет на середине.
|
|
29
|
+
// window-aware routing всё равно исключает малые окна (or-lfm
|
|
30
|
+
// 65k) для больших запросов, так что запас к окнам сохранён.
|
|
26
31
|
|
|
27
32
|
// Reliability-ordered long-context summarizers. Порядок важен: getSummary идёт
|
|
28
33
|
// по списку и ждёт ПЕРВЫЙ отвечающий (до 30с). Медленные/непроверенные в начале
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Снятие периодических срезов метрик для сравнения «до/после».
|
|
2
|
+
// Каждые INTERVAL_MS пишет точку {ts, successRate, requests, errors, compactCount,
|
|
3
|
+
// poolActive, poolTotal, errorsByClass, version} в diag_history.json. Данные
|
|
4
|
+
// переживают перезапуск (пишутся на диск) — можно строить тренд по времени.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { getStats, getHealth, getContextStats } = require('./health');
|
|
8
|
+
|
|
9
|
+
const HISTORY_PATH = process.env.DIAG_HISTORY_PATH || path.join(__dirname, '..', 'diag_history.json');
|
|
10
|
+
const INTERVAL_MS = 30 * 60 * 1000; // 30 мин
|
|
11
|
+
const MAX_POINTS = 2000;
|
|
12
|
+
|
|
13
|
+
function classifyErrors(errors) {
|
|
14
|
+
// Грубая классификация по статусу провайдера в health (без сетевых вызовов):
|
|
15
|
+
// 429 → limit; 401/402/403 → auth; прочее/нет данных → invalid.
|
|
16
|
+
const health = getHealth();
|
|
17
|
+
const out = { limit: 0, auth: 0, invalid: 0, timeout: 0 };
|
|
18
|
+
for (const [key, cnt] of Object.entries(errors || {})) {
|
|
19
|
+
const h = health[key] || {};
|
|
20
|
+
if (h.statusCode === 429) out.limit += cnt;
|
|
21
|
+
else if ([401, 402, 403].includes(h.statusCode)) out.auth += cnt;
|
|
22
|
+
else if (h.statusCode === 0) out.timeout += cnt;
|
|
23
|
+
else out.invalid += cnt;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function snapshot() {
|
|
29
|
+
const stats = getStats() || {};
|
|
30
|
+
const ctx = getContextStats() || {};
|
|
31
|
+
const health = getHealth() || {};
|
|
32
|
+
const errors = stats.errors || {};
|
|
33
|
+
const today = todayStat(stats);
|
|
34
|
+
const active = Object.values(health).filter((h) => h.status === 'up' || h.status === 'ratelimited').length;
|
|
35
|
+
return {
|
|
36
|
+
ts: Date.now(),
|
|
37
|
+
version: process.env.FREEGATE_VERSION || currentVersion(),
|
|
38
|
+
requests: today.requests,
|
|
39
|
+
success: today.success,
|
|
40
|
+
failed: today.failed,
|
|
41
|
+
successRate: today.successRate,
|
|
42
|
+
compactCount: ctx.compactCount || 0,
|
|
43
|
+
upgradedCount: ctx.upgradedCount || 0,
|
|
44
|
+
poolActive: active,
|
|
45
|
+
poolTotal: Object.keys(health).length,
|
|
46
|
+
errorsByClass: classifyErrors(errors),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function todayStat(stats) {
|
|
51
|
+
// Дневные счётчики из stats.today (вычисляется сервером) с fallback на общие.
|
|
52
|
+
const day = stats.today || {};
|
|
53
|
+
const total = day.requests || 0;
|
|
54
|
+
return {
|
|
55
|
+
requests: total,
|
|
56
|
+
success: day.success || 0,
|
|
57
|
+
failed: day.failed || 0,
|
|
58
|
+
successRate: total > 0 ? Math.round(((day.success || 0) / total) * 100) : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function currentVersion() {
|
|
63
|
+
try { return require('../package.json').version; } catch { return 'dev'; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadHistory() {
|
|
67
|
+
try { return JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8')); }
|
|
68
|
+
catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function recordSnapshot() {
|
|
72
|
+
const hist = loadHistory();
|
|
73
|
+
hist.push(snapshot());
|
|
74
|
+
while (hist.length > MAX_POINTS) hist.shift();
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(HISTORY_PATH, JSON.stringify(hist, null, 2));
|
|
77
|
+
} catch {}
|
|
78
|
+
return hist.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let _timer = null;
|
|
82
|
+
function startMonitor(intervalMs = INTERVAL_MS) {
|
|
83
|
+
stopMonitor();
|
|
84
|
+
recordSnapshot(); // сразу фиксируем стартовую точку
|
|
85
|
+
_timer = setInterval(recordSnapshot, intervalMs);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stopMonitor() {
|
|
89
|
+
if (_timer) { clearInterval(_timer); _timer = null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { snapshot, recordSnapshot, loadHistory, startMonitor, stopMonitor, INTERVAL_MS };
|
package/lib/providers.js
CHANGED
|
@@ -132,7 +132,7 @@ const MODEL_MAP = {
|
|
|
132
132
|
'gemini-3.6-flash': 'gemini-flash',
|
|
133
133
|
'codestral-latest': 'mistral-codestral', 'mistral-small-latest': 'mistral-small',
|
|
134
134
|
'mistral-medium-latest': 'mistral-small', 'mistral-large-latest': 'mistral-small',
|
|
135
|
-
'tier-splus': 'or-
|
|
135
|
+
'tier-splus': 'or-dots-3', 'tier-s': 'mistral-codestral',
|
|
136
136
|
'tier-a': 'mistral-small', 'tier-b': 'groq-qwen',
|
|
137
137
|
'tier-xl': 'or-dots-3', 'tier-l': 'or-minimax-m3-free',
|
|
138
138
|
'openrouter-hermes': 'openrouter-hermes',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freegate",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.24",
|
|
4
4
|
"description": "Free multi-provider LLM gateway with automatic failover. One OpenAI-compatible endpoint routes to many free models (Groq, Mistral, Gemini, NIM, OpenRouter, ZAI, Cerebras, DeepSeek and more). Never pay for LLMs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node server.js",
|
|
11
|
-
"test": "node --test --test-concurrency=1 test/proxy.test.js test/clean.test.js"
|
|
11
|
+
"test": "node --test --test-concurrency=1 test/proxy.test.js test/clean.test.js",
|
|
12
|
+
"set-model-version": "node scripts/set-model-version.js",
|
|
13
|
+
"release": "npm test && npm run set-model-version && npm publish"
|
|
12
14
|
},
|
|
13
15
|
"engines": {
|
|
14
16
|
"node": ">=18"
|
package/providers.json
CHANGED
|
@@ -646,7 +646,7 @@
|
|
|
646
646
|
"gemini-gemma-4-26b-a4b-it": {
|
|
647
647
|
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
648
648
|
"model": "gemma-4-26b-a4b-it",
|
|
649
|
-
"priority":
|
|
649
|
+
"priority": 7,
|
|
650
650
|
"dailyLimit": 1500,
|
|
651
651
|
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
652
652
|
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
@@ -666,7 +666,7 @@
|
|
|
666
666
|
"gemini-gemini-flash-lite-latest": {
|
|
667
667
|
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
668
668
|
"model": "gemini-flash-lite-latest",
|
|
669
|
-
"priority":
|
|
669
|
+
"priority": 3,
|
|
670
670
|
"dailyLimit": 1500,
|
|
671
671
|
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
672
672
|
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
@@ -676,7 +676,7 @@
|
|
|
676
676
|
"gemini-gemini-3-1-flash-lite-preview": {
|
|
677
677
|
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
678
678
|
"model": "gemini-3.1-flash-lite-preview",
|
|
679
|
-
"priority":
|
|
679
|
+
"priority": 2,
|
|
680
680
|
"dailyLimit": 1500,
|
|
681
681
|
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
682
682
|
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
@@ -686,7 +686,7 @@
|
|
|
686
686
|
"gemini-gemini-3-5-flash": {
|
|
687
687
|
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
688
688
|
"model": "gemini-3.5-flash",
|
|
689
|
-
"priority":
|
|
689
|
+
"priority": 6,
|
|
690
690
|
"dailyLimit": 1500,
|
|
691
691
|
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
692
692
|
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
@@ -706,7 +706,7 @@
|
|
|
706
706
|
"gemini-gemini-3-1-flash-lite": {
|
|
707
707
|
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
708
708
|
"model": "gemini-3.1-flash-lite",
|
|
709
|
-
"priority":
|
|
709
|
+
"priority": 5,
|
|
710
710
|
"dailyLimit": 1500,
|
|
711
711
|
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
712
712
|
"envVar": "PROVIDER_GEMINI_APIKEY",
|
package/server.js
CHANGED
|
@@ -162,6 +162,8 @@ const MODEL_MANAGER_CONFIG = Object.assign(
|
|
|
162
162
|
);
|
|
163
163
|
const autoUpdate = require('./lib/autoupdate');
|
|
164
164
|
const AUTO_UPDATE = (config.autoUpdate && typeof config.autoUpdate === 'object') ? config.autoUpdate : {};
|
|
165
|
+
const diagMonitor = require('./lib/diagmonitor');
|
|
166
|
+
const DIAG_MONITOR = (config.diagMonitor && typeof config.diagMonitor === 'object') ? config.diagMonitor : { enabled: true, intervalMs: 30 * 60 * 1000 };
|
|
165
167
|
const modelManager = new ModelManager({
|
|
166
168
|
dbPath: path.join(__dirname, 'models-db.json'),
|
|
167
169
|
catalogPath: path.join(__dirname, 'providers.json'),
|
|
@@ -557,8 +559,11 @@ async function handleChatCompletion(req, res, body) {
|
|
|
557
559
|
}
|
|
558
560
|
}
|
|
559
561
|
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
+
// Tool-запросы (агент вызывает инструмент) НЕ кэшируем: ответ зависит от tool_calls,
|
|
563
|
+
// а кэш по messages может отдать текстовый ответ вместо инструмента — агент
|
|
564
|
+
// «останавливается», не получив tool. Идём всегда к провайдеру.
|
|
565
|
+
const hasTools = !!(body.tools || body.tool_choice);
|
|
566
|
+
const cached = hasTools ? null : cache.get(effectiveModel, body.messages, body.temperature, body.tools || body.tool_choice);
|
|
562
567
|
if (cached) {
|
|
563
568
|
logger.request({ model: requestedModel, provider: 'cache', status: 200, cached: true });
|
|
564
569
|
recordRecent({ model: requestedModel, provider: 'cache', status: 200, latency: 0, cached: true });
|
|
@@ -569,7 +574,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
569
574
|
}
|
|
570
575
|
|
|
571
576
|
// Semantic cache: same intent, rephrased wording → replay without a new LLM call.
|
|
572
|
-
if (SEMCACHE_CONFIG.enabled) {
|
|
577
|
+
if (SEMCACHE_CONFIG.enabled && !hasTools) {
|
|
573
578
|
const semantic = cache.getSemantic(effectiveModel, body.messages, body.temperature, SEMCACHE_CONFIG.minSimilarity);
|
|
574
579
|
if (semantic) {
|
|
575
580
|
logger.request({ model: requestedModel, provider: 'semcache', status: 200, cached: true });
|
|
@@ -905,7 +910,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
905
910
|
model: provider.model,
|
|
906
911
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
907
912
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
908
|
-
}, key);
|
|
913
|
+
}, key, body.tools || body.tool_choice);
|
|
909
914
|
}
|
|
910
915
|
commit(200);
|
|
911
916
|
res.end();
|
|
@@ -1015,7 +1020,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
1015
1020
|
model: provider.model,
|
|
1016
1021
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
1017
1022
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
1018
|
-
}, key);
|
|
1023
|
+
}, key, body.tools || body.tool_choice);
|
|
1019
1024
|
}
|
|
1020
1025
|
commit(200);
|
|
1021
1026
|
res.end();
|
|
@@ -1064,7 +1069,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
1064
1069
|
}
|
|
1065
1070
|
}
|
|
1066
1071
|
commit(200);
|
|
1067
|
-
cache.set(effectiveModel, body.messages, body.temperature, result.data, key);
|
|
1072
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data, key, body.tools || body.tool_choice);
|
|
1068
1073
|
recordTokens(key, result.usage);
|
|
1069
1074
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1070
1075
|
res.end(JSON.stringify(result.data));
|
|
@@ -1161,7 +1166,7 @@ if (isTooShort(result.data, lastUserText(body.messages))) {
|
|
|
1161
1166
|
measure.real = (result.data.usage && result.data.usage.prompt_tokens) ? result.data.usage.prompt_tokens : measure.sentTokens || 0;
|
|
1162
1167
|
measure.win = PROVIDERS[key]?.context_window || 0;
|
|
1163
1168
|
commit(200);
|
|
1164
|
-
cache.set(effectiveModel, body.messages, body.temperature, result.data, key);
|
|
1169
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data, key, body.tools || body.tool_choice);
|
|
1165
1170
|
recordTokens(key, result.usage);
|
|
1166
1171
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1167
1172
|
res.end(JSON.stringify(result.data));
|
|
@@ -1219,7 +1224,7 @@ if (isTooShort(result.data, lastUserText(body.messages))) {
|
|
|
1219
1224
|
model: provider.model,
|
|
1220
1225
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
1221
1226
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
1222
|
-
}, key);
|
|
1227
|
+
}, key, body.tools || body.tool_choice);
|
|
1223
1228
|
}
|
|
1224
1229
|
commit(200);
|
|
1225
1230
|
res.end();
|
|
@@ -1756,10 +1761,16 @@ server.listen(PORT, process.env.HOST || '127.0.0.1', () => {
|
|
|
1756
1761
|
autoUpdate.startAutoUpdate({ root: __dirname, log: (m) => logger.info(m) });
|
|
1757
1762
|
logger.info('AutoUpdate enabled', { intervalMs: AUTO_UPDATE.intervalMs });
|
|
1758
1763
|
}
|
|
1764
|
+
// Мониторинг метрик: снапшот каждые 30 мин → можно сравнить «до/после» фиксов.
|
|
1765
|
+
if (DIAG_MONITOR.enabled) {
|
|
1766
|
+
diagMonitor.startMonitor(DIAG_MONITOR.intervalMs);
|
|
1767
|
+
logger.info('DiagMonitor started', { intervalMs: DIAG_MONITOR.intervalMs });
|
|
1768
|
+
}
|
|
1759
1769
|
});
|
|
1760
1770
|
|
|
1761
1771
|
const _shutdown = () => {
|
|
1762
1772
|
if (memStore) { memStore.stopTimer(); memStore.save(); }
|
|
1773
|
+
diagMonitor.stopMonitor();
|
|
1763
1774
|
require('./lib/health').saveState();
|
|
1764
1775
|
cache.persist();
|
|
1765
1776
|
try { modelManager.stop(); } catch {}
|