freegate 0.6.17 → 0.6.20
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/.env.example +13 -1
- package/README.md +19 -7
- package/README.ru.md +21 -7
- package/assets/dashboard-en.gif +0 -0
- package/assets/dashboard-en.png +0 -0
- package/assets/dashboard-models-en.png +0 -0
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.gif +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +122 -0
- package/config.example.json +39 -0
- package/lib/cache.js +19 -4
- package/lib/compress.js +85 -0
- package/lib/contextstats.js +2 -2
- package/lib/dashboard.js +378 -135
- package/lib/doctor.js +106 -0
- package/lib/health.js +48 -0
- package/lib/methodology.js +23 -2
- package/lib/modelscan.js +7 -2
- package/lib/onboarding.js +79 -0
- package/lib/setup.js +136 -0
- package/lib/strategy.js +57 -0
- package/lib/taskclassify.js +44 -3
- package/lib/vetting.js +91 -0
- package/lib/websearch.js +75 -0
- package/package.json +1 -1
- package/providers.json +264 -0
- package/server.js +127 -9
package/server.js
CHANGED
|
@@ -4,11 +4,12 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { LRUCache } = require('./lib/cache');
|
|
6
6
|
const { PROVIDERS, MODEL_MAP, callProvider, reloadProviders } = require('./lib/providers');
|
|
7
|
-
const { loadState, initHealth, isCircuitOpen, recordSuccess, recordFailure, recordRequest, recordTokens, getHealth, getStats, getReliability, recordRecent, recordRpm, getRecent, getRpm, recordSelection, getLastSelection, getBandit, recordBandit, warmBanditPriors, getContextStats } = require('./lib/health');
|
|
7
|
+
const { loadState, initHealth, isCircuitOpen, recordSuccess, recordFailure, recordRequest, recordTokens, getHealth, getStats, getReliability, recordRecent, recordRpm, getRecent, getRpm, recordSelection, getLastSelection, getBandit, recordBandit, warmBanditPriors, getContextStats, getHourly } = require('./lib/health');
|
|
8
8
|
const { checkRateLimit } = require('./lib/rateLimit');
|
|
9
9
|
const { handleDashboard } = require('./lib/dashboard');
|
|
10
10
|
const { acquire, stats: poolStats } = require('./lib/pool');
|
|
11
11
|
const { aggregateSavings } = require('./lib/economics');
|
|
12
|
+
const websearch = require('./lib/websearch');
|
|
12
13
|
const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage, isTooShort, MIN_ANSWER_LEN } = require('./lib/clean');
|
|
13
14
|
const { classifyComplexity, maybeUpgradeTier, classifyVisionComplexity, needsWindowUpgrade } = require('./lib/routing');
|
|
14
15
|
const { bucket, pick: banditPick, isTransientLimit } = require('./lib/bandit');
|
|
@@ -42,7 +43,10 @@ const warmed = warmBanditPriors(
|
|
|
42
43
|
);
|
|
43
44
|
if (warmed > 0) logger.info('Warmed bandit priors', { warmed });
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// Диск-кэш: повторы промптов не жгут free-лимиты. TTL 24ч (чаты/агенты часто
|
|
47
|
+
// переотправляют одинаковые запросы — ретраи, повторные вопросы; дневной кэш
|
|
48
|
+
// снимает лишнюю нагрузку). Семантический normalize ON (4-й арг).
|
|
49
|
+
const cache = new LRUCache(500, 24 * 3600 * 1000, false, true);
|
|
46
50
|
require('./lib/cache')._activeCache = cache;
|
|
47
51
|
|
|
48
52
|
// Load config (with fallback so a corrupt config never crashes the server)
|
|
@@ -95,6 +99,39 @@ const METHODOLOGY_CONFIG = Object.assign(
|
|
|
95
99
|
(config.methodology && typeof config.methodology === 'object') ? config.methodology : {}
|
|
96
100
|
);
|
|
97
101
|
|
|
102
|
+
// --- Самопроверка ответа второй моделью (vetting) ---
|
|
103
|
+
// Опционально: после не-stream ответа отправляем краткий чек другой модели.
|
|
104
|
+
// Выключено по умолчанию (жжёт 2-й free-лимит). Включается config.vetting.enabled.
|
|
105
|
+
const { shouldVet, vetAnswer, VETTING_DEFAULTS } = require('./lib/vetting');
|
|
106
|
+
const VETTING_CONFIG = Object.assign(
|
|
107
|
+
{ ...VETTING_DEFAULTS },
|
|
108
|
+
(config.vetting && typeof config.vetting === 'object') ? config.vetting : {}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// --- Стратегия роутинга ---
|
|
112
|
+
// Опциональные модификаторы равномерности (round-robin / least-used).
|
|
113
|
+
// По умолчанию 'weighted' — поведение без изменений.
|
|
114
|
+
const { makeWeightModifier } = require('./lib/strategy');
|
|
115
|
+
const ROUTING_STRATEGY = (config.routing && config.routing.strategy) || 'weighted';
|
|
116
|
+
|
|
117
|
+
// --- Сжатие промпта (Caveman-стиль) ---
|
|
118
|
+
// Опционально убирает вежливость/заполнители из последнего user-сообщения,
|
|
119
|
+
// экономя токены. config.compress.enabled=true включает.
|
|
120
|
+
const { compressMessages } = require('./lib/compress');
|
|
121
|
+
const COMPRESS_CONFIG = Object.assign(
|
|
122
|
+
{ enabled: false, minLen: 60 },
|
|
123
|
+
(config.compress && typeof config.compress === 'object') ? config.compress : {}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// --- Веб-поиск для search-задач ---
|
|
127
|
+
// Бесплатный поиск фактов (DuckDuckGo, без ключа) для запросов-поиска, чтобы
|
|
128
|
+
// модель не галлюцинировала («что такое минимакс дизайн» → реальная инфа про
|
|
129
|
+
// MiniMax, а не «минимализм»). Только для search-задач. Сбои не ломают запрос.
|
|
130
|
+
const WEBSEARCH_CONFIG = Object.assign(
|
|
131
|
+
{ enabled: true, limit: 5, timeout: 8000, queryMinChars: 6 },
|
|
132
|
+
(config.websearch && typeof config.websearch === 'object') ? config.websearch : {}
|
|
133
|
+
);
|
|
134
|
+
|
|
98
135
|
// --- Самообновляющаяся база моделей (Model Discovery Engine) ---
|
|
99
136
|
// Планировщик живёт внутри сервера: работает «всегда» у всех пользователей
|
|
100
137
|
// пакета без cron/launchd. config.modelManager.enabled=false отключает.
|
|
@@ -446,6 +483,15 @@ async function handleChatCompletion(req, res, body) {
|
|
|
446
483
|
if (METHODOLOGY_CONFIG.enabled && Array.isArray(body.messages)) {
|
|
447
484
|
try {
|
|
448
485
|
taskCategory = classifyTask(body.messages);
|
|
486
|
+
// Сжатие промпта: убираем вежливость/заполнители ДО методолога (методолог
|
|
487
|
+
// не должен суммировать сжатый текст). Опционально (config.compress).
|
|
488
|
+
if (COMPRESS_CONFIG.enabled) {
|
|
489
|
+
const compressed = compressMessages(body.messages, COMPRESS_CONFIG);
|
|
490
|
+
if (compressed !== body.messages && Array.isArray(compressed)) {
|
|
491
|
+
body.messages = compressed;
|
|
492
|
+
measure.compressed = 1;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
449
495
|
const injected = injectMethodology(body.messages, taskCategory, METHODOLOGY_CONFIG);
|
|
450
496
|
if (injected !== body.messages) {
|
|
451
497
|
body.messages = injected;
|
|
@@ -456,6 +502,35 @@ async function handleChatCompletion(req, res, body) {
|
|
|
456
502
|
}
|
|
457
503
|
}
|
|
458
504
|
|
|
505
|
+
// --- Веб-поиск для search-задач ---
|
|
506
|
+
// Нашли факты из интернета (DuckDuckGo, без ключа) и подмешали как контекст,
|
|
507
|
+
// чтобы модель отвечала по существу, а не галлюцинировала. Только search.
|
|
508
|
+
// Любой сбой (нет сети/таймаут/пусто) = просто пропускаем, запрос идёт как есть.
|
|
509
|
+
let searchContext = '';
|
|
510
|
+
if (WEBSEARCH_CONFIG.enabled && (taskCategory === 'search' || taskCategory === 'chat')) {
|
|
511
|
+
try {
|
|
512
|
+
const userTexts = (body.messages || [])
|
|
513
|
+
.filter(m => m && m.role === 'user' && typeof m.content === 'string')
|
|
514
|
+
.map(m => m.content.trim());
|
|
515
|
+
const query = userTexts[userTexts.length - 1] || '';
|
|
516
|
+
if (query.length >= WEBSEARCH_CONFIG.queryMinChars) {
|
|
517
|
+
const results = await websearch.search(query, {
|
|
518
|
+
fetchImpl: (url, opts) => fetch(url, opts),
|
|
519
|
+
limit: WEBSEARCH_CONFIG.limit,
|
|
520
|
+
timeout: WEBSEARCH_CONFIG.timeout,
|
|
521
|
+
});
|
|
522
|
+
if (results.length > 0) {
|
|
523
|
+
searchContext = websearch.toContext(results, query);
|
|
524
|
+
measure.websearch = true;
|
|
525
|
+
body.messages.push({ role: 'system', content: searchContext });
|
|
526
|
+
logger.info('Websearch', { query: query.slice(0, 80), results: results.length });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
logger.error('Websearch error', { message: err.message });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
459
534
|
// Replays a previously cached completion (exact or semantic hit), preserving
|
|
460
535
|
// the stream/non-stream shape the client asked for.
|
|
461
536
|
function serveCached(res, cached, isStreaming) {
|
|
@@ -566,6 +641,9 @@ async function handleChatCompletion(req, res, body) {
|
|
|
566
641
|
.sort((a, b) => (b.provider.context_window || 0) - (a.provider.context_window || 0))
|
|
567
642
|
.slice(0, 1);
|
|
568
643
|
} else {
|
|
644
|
+
const usedTodayList = {};
|
|
645
|
+
for (const [k] of pool) usedTodayList[k] = usedTodayFor(k);
|
|
646
|
+
const strategyModifier = makeWeightModifier(ROUTING_STRATEGY, { keys: pool.map(([k]) => k), usedTodayList });
|
|
569
647
|
const scored = pool.map(([key, provider]) => {
|
|
570
648
|
const h = getHealth()[key];
|
|
571
649
|
let score = h.score || 50;
|
|
@@ -578,12 +656,19 @@ async function handleChatCompletion(req, res, body) {
|
|
|
578
656
|
// не исключая fallback. coding→coding, reasoning→reasoning, chat/search→general.
|
|
579
657
|
const cat = provider.category || 'general';
|
|
580
658
|
if (taskCategory === 'coding' && cat === 'coding') weight *= 1.5;
|
|
659
|
+
else if (taskCategory === 'design' && cat === 'coding') weight *= 1.6;
|
|
581
660
|
else if (taskCategory === 'reasoning' && cat === 'reasoning') weight *= 1.5;
|
|
582
661
|
else if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'general') weight *= 1.2;
|
|
662
|
+
// Дизайн — это UI-код: визуальные рекомендации идут от coding-моделей.
|
|
663
|
+
else if (taskCategory === 'design' && cat === 'reasoning') weight *= 0.4;
|
|
583
664
|
// Простой факт/поиск не должен «думать вслух» на reasoning-модели:
|
|
584
665
|
// это дорого по лимитам и даёт размышления вместо краткого ответа.
|
|
585
666
|
if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'reasoning') weight *= 0.15;
|
|
586
667
|
else if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'vision') weight *= 0.3;
|
|
668
|
+
// Поиск фактов — не кодинг-задача: codestral (coding) на вопросе
|
|
669
|
+
// «что такое минимакс дизайн» выдаёт рассуждение про «минимализм».
|
|
670
|
+
// Отдаём search на general-модели (minimax/small), а не на кодеров.
|
|
671
|
+
if (taskCategory === 'search' && cat === 'coding') weight *= 0.3;
|
|
587
672
|
const dailyLimit = provider.dailyLimit || 0;
|
|
588
673
|
// Провайдер, исчерпавший дневной лимит (попал в пул лишь как крайний
|
|
589
674
|
// резерв, когда живы все выгорели), — сильно штрафуем, чтобы выбрать
|
|
@@ -591,6 +676,9 @@ async function handleChatCompletion(req, res, body) {
|
|
|
591
676
|
const usedToday = usedTodayFor(key);
|
|
592
677
|
if (dailyLimit > 0 && usedToday >= dailyLimit) weight *= 0.03;
|
|
593
678
|
else if (dailyLimit > 0 && usedToday >= dailyLimit * 0.9) weight *= 0.4;
|
|
679
|
+
// Стратегия роутинга: равномерность (round-robin / least-used) как
|
|
680
|
+
// лёгкий модификатор к базовому weight — не ломает основной скоринг.
|
|
681
|
+
weight *= strategyModifier(key);
|
|
594
682
|
return { key, provider, weight };
|
|
595
683
|
});
|
|
596
684
|
|
|
@@ -634,13 +722,19 @@ async function handleChatCompletion(req, res, body) {
|
|
|
634
722
|
const tLimit = (getStats().dailyUsage?.[targetProviderKey]?.[today]) || 0;
|
|
635
723
|
const tCap = PROVIDERS[targetProviderKey].dailyLimit || 0;
|
|
636
724
|
const targetBurned = tHealth?.status === 'ratelimited' || (tCap > 0 && tLimit >= tCap * 0.9);
|
|
637
|
-
|
|
725
|
+
// Кодинг-target (codestral) не подходит для поиска фактов: на вопросе
|
|
726
|
+
// «что такое минимакс дизайн» он отвечает «минимализм», а не про нейросеть.
|
|
727
|
+
// Для search/chat-задач НЕ ставим кодинг-модель первой — пусть выберётся
|
|
728
|
+
// general-модель (minimax/small), которая отвечает по существу.
|
|
729
|
+
const targetIsCoding = (PROVIDERS[targetProviderKey].category || '') === 'coding';
|
|
730
|
+
const targetMismatch = (taskCategory === 'search' || taskCategory === 'chat') && targetIsCoding;
|
|
731
|
+
if (!targetBurned && !targetMismatch) {
|
|
638
732
|
enabledProviders = [
|
|
639
733
|
[targetProviderKey, PROVIDERS[targetProviderKey]],
|
|
640
734
|
...enabledProviders.filter(([k]) => k !== targetProviderKey),
|
|
641
735
|
];
|
|
642
736
|
} else {
|
|
643
|
-
logger.info('Target-first skip', { key: targetProviderKey, status: tHealth?.status, used: tLimit, cap: tCap });
|
|
737
|
+
logger.info('Target-first skip', { key: targetProviderKey, status: tHealth?.status, used: tLimit, cap: tCap, mismatch: targetMismatch });
|
|
644
738
|
}
|
|
645
739
|
}
|
|
646
740
|
|
|
@@ -781,7 +875,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
781
875
|
model: provider.model,
|
|
782
876
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
783
877
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
784
|
-
});
|
|
878
|
+
}, key);
|
|
785
879
|
}
|
|
786
880
|
commit(200);
|
|
787
881
|
res.end();
|
|
@@ -891,7 +985,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
891
985
|
model: provider.model,
|
|
892
986
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
893
987
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
894
|
-
});
|
|
988
|
+
}, key);
|
|
895
989
|
}
|
|
896
990
|
commit(200);
|
|
897
991
|
res.end();
|
|
@@ -916,8 +1010,31 @@ async function handleChatCompletion(req, res, body) {
|
|
|
916
1010
|
measure.provider = key;
|
|
917
1011
|
measure.real = (result.data.usage && result.data.usage.prompt_tokens) ? result.data.usage.prompt_tokens : measure.sentTokens || 0;
|
|
918
1012
|
measure.win = PROVIDERS[key]?.context_window || 0;
|
|
1013
|
+
// Самопроверка второй моделью (опционально): только не-stream, только по конфигу.
|
|
1014
|
+
if (VETTING_CONFIG.enabled && !isStreaming) {
|
|
1015
|
+
const answer = result.data?.choices?.[0]?.message?.content || '';
|
|
1016
|
+
if (shouldVet({ config: VETTING_CONFIG, complexity, category: taskCategory, answerLen: answer.length })) {
|
|
1017
|
+
const picks = Object.entries(PROVIDERS)
|
|
1018
|
+
.filter(([pk, p]) => p.enabled && pk !== key && !isCircuitOpen(pk) &&
|
|
1019
|
+
getHealth()[pk]?.status === 'up' && p.vision !== true)
|
|
1020
|
+
.map(([_, p]) => p);
|
|
1021
|
+
try {
|
|
1022
|
+
const verdict = await vetAnswer({ answer, callProvider, picks, config: VETTING_CONFIG });
|
|
1023
|
+
if (verdict.checked && !verdict.ok && verdict.note) {
|
|
1024
|
+
const msg = result.data.choices[0].message;
|
|
1025
|
+
msg.content = (msg.content || '') + '\n\n> ⚠️ Проверка второй моделью: ' + verdict.note;
|
|
1026
|
+
measure.vetted = 1;
|
|
1027
|
+
measure.vetNote = verdict.note;
|
|
1028
|
+
} else {
|
|
1029
|
+
measure.vetted = 0;
|
|
1030
|
+
}
|
|
1031
|
+
} catch (vetErr) {
|
|
1032
|
+
measure.vetted = 0;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
919
1036
|
commit(200);
|
|
920
|
-
cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
1037
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data, key);
|
|
921
1038
|
recordTokens(key, result.usage);
|
|
922
1039
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
923
1040
|
res.end(JSON.stringify(result.data));
|
|
@@ -1004,7 +1121,7 @@ if (isTooShort(result.data, lastUserText(body.messages))) {
|
|
|
1004
1121
|
measure.real = (result.data.usage && result.data.usage.prompt_tokens) ? result.data.usage.prompt_tokens : measure.sentTokens || 0;
|
|
1005
1122
|
measure.win = PROVIDERS[key]?.context_window || 0;
|
|
1006
1123
|
commit(200);
|
|
1007
|
-
cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
1124
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data, key);
|
|
1008
1125
|
recordTokens(key, result.usage);
|
|
1009
1126
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1010
1127
|
res.end(JSON.stringify(result.data));
|
|
@@ -1062,7 +1179,7 @@ if (isTooShort(result.data, lastUserText(body.messages))) {
|
|
|
1062
1179
|
model: provider.model,
|
|
1063
1180
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
1064
1181
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
1065
|
-
});
|
|
1182
|
+
}, key);
|
|
1066
1183
|
}
|
|
1067
1184
|
commit(200);
|
|
1068
1185
|
res.end();
|
|
@@ -1291,6 +1408,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1291
1408
|
return [k, { status: v.status, score: v.score, latency_ms: v.latency, reason, reliability }];
|
|
1292
1409
|
})),
|
|
1293
1410
|
cache: cache.stats(),
|
|
1411
|
+
hourly: (() => { try { return getHourly(); } catch { return []; } })(),
|
|
1294
1412
|
limits,
|
|
1295
1413
|
pool: poolStats(),
|
|
1296
1414
|
last_selection: getLastSelection(),
|