freegate 0.6.18 → 0.6.21
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 +9 -4
- package/README.ru.md +9 -3
- 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.gif +0 -0
- package/bin/freegate.js +107 -0
- package/config.example.json +33 -0
- package/lib/cache.js +19 -4
- package/lib/compress.js +85 -0
- package/lib/dashboard.js +156 -11
- package/lib/doctor.js +106 -0
- package/lib/health.js +48 -0
- package/lib/modelmanager.js +14 -0
- 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/vetting.js +91 -0
- package/package.json +1 -1
- package/providers.json +335 -1
- package/server.js +120 -6
package/lib/dashboard.js
CHANGED
|
@@ -238,6 +238,19 @@ const HTML = `<!DOCTYPE html>
|
|
|
238
238
|
.status-wait { color: var(--muted); font-size: 12px; }
|
|
239
239
|
.hidden { display: none; }
|
|
240
240
|
|
|
241
|
+
/* ── Опции оптимизации ── */
|
|
242
|
+
.opt-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 2px; font-size: 13.5px; font-weight: 600; }
|
|
243
|
+
.opt-switch { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
|
244
|
+
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
|
|
245
|
+
.switch input { display: none; }
|
|
246
|
+
.slider { position: absolute; inset: 0; background: var(--panel2); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: all .15s ease; }
|
|
247
|
+
.slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; background: var(--muted); border-radius: 50%; transition: all .15s ease; }
|
|
248
|
+
.switch input:checked + .slider { background: rgba(124,92,255,.35); border-color: var(--accent); }
|
|
249
|
+
.switch input:checked + .slider::before { transform: translateX(18px); background: #fff; }
|
|
250
|
+
.opt-hint { font-size: 12px; color: var(--accent-dim); font-weight: 500; }
|
|
251
|
+
.opt-desc { font-size: 12px; color: var(--muted); line-height: 1.55; margin: 0 0 8px; }
|
|
252
|
+
.opt-note { font-size: 12px; color: var(--ok); margin-top: 8px; }
|
|
253
|
+
|
|
241
254
|
@media (max-width: 900px) {
|
|
242
255
|
#sidebar { width: 64px; padding: 20px 8px; }
|
|
243
256
|
.side-brand .name, .tab .label, .side-user .info { display: none; }
|
|
@@ -279,7 +292,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
279
292
|
|
|
280
293
|
<div id="main">
|
|
281
294
|
<header id="topbar">
|
|
282
|
-
<div class="brand"><span id="statusDot" class="dot"></span
|
|
295
|
+
<div class="brand"><span id="statusDot" class="dot"></span><span data-i18n="topTitle"></span></div>
|
|
283
296
|
<div class="theme-switch" title="">
|
|
284
297
|
<button class="theme-btn" id="themeBtn" onclick="cycleTheme()"></button>
|
|
285
298
|
</div>
|
|
@@ -307,9 +320,21 @@ const HTML = `<!DOCTYPE html>
|
|
|
307
320
|
<div class="card"><h2 data-i18n="cardPool"></h2><div id="pool" data-i18n="loading"></div></div>
|
|
308
321
|
<div class="card"><h2 data-i18n="cardSavings"></h2><div id="savings" data-i18n="loading"></div></div>
|
|
309
322
|
</div>
|
|
310
|
-
<div class="
|
|
323
|
+
<div class="grid">
|
|
324
|
+
<div class="card"><h2 data-i18n="cardRpm"></h2><canvas id="rpmChart"></canvas></div>
|
|
325
|
+
<div class="card"><h2 data-i18n="cardSpark"></h2><canvas id="sparkline"></canvas></div>
|
|
326
|
+
</div>
|
|
311
327
|
<div class="card" style="margin-top:16px;">
|
|
312
328
|
<h2 data-i18n="cardRecent"></h2>
|
|
329
|
+
<div class="toolbar">
|
|
330
|
+
<input id="recentSearch" class="search" type="text" placeholder="" data-i18n-ph="searchRecent" oninput="renderRecent(_recent)">
|
|
331
|
+
<select id="recentStatusFilter" class="filter" onchange="renderRecent(_recent)">
|
|
332
|
+
<option value="all" data-i18n="filterAll"></option>
|
|
333
|
+
<option value="ok" data-i18n="recentOk"></option>
|
|
334
|
+
<option value="err" data-i18n="recentErr"></option>
|
|
335
|
+
</select>
|
|
336
|
+
<span class="count-hint" id="recentCount"></span>
|
|
337
|
+
</div>
|
|
313
338
|
<div class="table-wrap"><table id="recentTable"></table></div>
|
|
314
339
|
</div>
|
|
315
340
|
</section>
|
|
@@ -363,6 +388,23 @@ const HTML = `<!DOCTYPE html>
|
|
|
363
388
|
<button class="btn btn-save" data-i18n="saveAllKeys" onclick="saveAllKeys()" style="margin-left: 8px;"></button>
|
|
364
389
|
<span id="save-status" class="status-wait" style="margin-left: 12px;"></span>
|
|
365
390
|
</div>
|
|
391
|
+
<div class="card" style="margin-bottom:14px;">
|
|
392
|
+
<h2 data-i18n="optBlock"></h2>
|
|
393
|
+
<div class="opt-row"><span data-i18n="optCompress"></span><div class="opt-switch"><label class="switch"><input type="checkbox" id="optCompress"><span class="slider"></span></label><span class="opt-hint" id="optCompressState"></span></div></div>
|
|
394
|
+
<div class="opt-desc" data-i18n="optCompressDesc"></div>
|
|
395
|
+
<div class="opt-row"><span data-i18n="optVetting"></span><div class="opt-switch"><label class="switch"><input type="checkbox" id="optVetting"><span class="slider"></span></label><span class="opt-hint" id="optVettingState"></span></div></div>
|
|
396
|
+
<div class="opt-desc" data-i18n="optVettingDesc"></div>
|
|
397
|
+
<div class="opt-row"><span data-i18n="optRouting"></span>
|
|
398
|
+
<select id="optRouting" class="filter" onchange="saveOpts()">
|
|
399
|
+
<option value="weighted" data-i18n="optWeighted"></option>
|
|
400
|
+
<option value="weighted-roundrobin" data-i18n="optRoundrobin"></option>
|
|
401
|
+
<option value="weighted-least" data-i18n="optLeast"></option>
|
|
402
|
+
</select>
|
|
403
|
+
<span class="opt-hint" id="optRoutingState"></span>
|
|
404
|
+
</div>
|
|
405
|
+
<div class="opt-desc" data-i18n="optRoutingDesc"></div>
|
|
406
|
+
<div class="opt-note" id="optSaveNote"></div>
|
|
407
|
+
</div>
|
|
366
408
|
<div id="setup-grid" class="setup-grid" data-i18n="loading"></div>
|
|
367
409
|
</section>
|
|
368
410
|
</main>
|
|
@@ -383,18 +425,24 @@ const HTML = `<!DOCTYPE html>
|
|
|
383
425
|
ru: {
|
|
384
426
|
tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
|
|
385
427
|
tabContext: 'Контекст', tabSetup: 'Настройки (ключи)',
|
|
386
|
-
|
|
428
|
+
optBlock: 'Опции оптимизации', optCompress: 'Сжатие промптов', optVetting: 'Самопроверка', optRouting: 'Стратегия роутинга',
|
|
429
|
+
optWeighted: 'weighted (умный)', optRoundrobin: 'weighted-roundrobin (по кругу)', optLeast: 'weighted-least (меньше нагруженные)',
|
|
430
|
+
optCompressDesc: 'Убирает вежливость и «мусорные» слова («пожалуйста», «привет», «наверное») из последнего сообщения — меньше токенов, быстрее и дешевле. Не трогает код.',
|
|
431
|
+
optVettingDesc: 'Сложные ответы (код/рассуждения) проверяет второй моделью и помечает ошибки. Тратит один лишний запрос на проверку.',
|
|
432
|
+
optRoutingDesc: 'Как Freegate выбирает провайдера: умный взвешенный, по кругу (равномерно), или наименее нагруженные.', optSaved: 'Сохранено! Перезапусти Freegate для применения.',
|
|
433
|
+
crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate', topTitle: 'Панель управления',
|
|
387
434
|
kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
|
|
388
435
|
kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
|
|
389
436
|
cardSavings: 'Экономия (против платных API)', savings: 'Сэкономлено', tokens: 'токенов',
|
|
390
437
|
savingsBasis: 'расчёт: input $3/M, output $15/M (репрезентативно)',
|
|
391
438
|
cardCache: 'Кэш', cardTokens: 'Токены', cardPool: 'Очередь',
|
|
392
|
-
cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы',
|
|
439
|
+
cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы', cardSpark: 'Успех сегодня (по часам)',
|
|
393
440
|
cardCtx: 'Контекст (узкое место)', cardTasks: 'Категории задач (24ч)',
|
|
394
441
|
loading: 'Загрузка...', noData: 'Нет данных',
|
|
395
442
|
cacheHits: 'Хиты', cacheMisses: 'Промахи', cacheSize: 'Размер', cacheRate: 'Точность',
|
|
396
443
|
poolAll: 'без очереди', poolActive: 'активных', poolWait: 'ждут',
|
|
397
|
-
searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...',
|
|
444
|
+
searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...', searchRecent: 'Поиск по модели/провайдеру...',
|
|
445
|
+
recentOk: 'OK', recentErr: 'Ошибки',
|
|
398
446
|
filterAll: 'Все', filterUp: 'Up', filterLimit: 'Лимит', filterError: 'Ошибка', filterUnknown: 'Неизвестно',
|
|
399
447
|
thStatus: 'Статус', thProvider: 'Провайдер', thLatency: 'Задержка', thReliability: 'Стабильность',
|
|
400
448
|
thLimit: 'Лимит', thReason: 'Причина', dot: '—',
|
|
@@ -420,18 +468,24 @@ const HTML = `<!DOCTYPE html>
|
|
|
420
468
|
en: {
|
|
421
469
|
tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
|
|
422
470
|
tabContext: 'Context', tabSetup: 'Settings (keys)',
|
|
423
|
-
|
|
471
|
+
optBlock: 'Optimization options', optCompress: 'Prompt compression', optVetting: 'Self-check', optRouting: 'Routing strategy',
|
|
472
|
+
optWeighted: 'weighted (smart)', optRoundrobin: 'weighted-roundrobin (round-robin)', optLeast: 'weighted-least (least-used)',
|
|
473
|
+
optCompressDesc: 'Strips politeness and filler words ("please", "hello", "probably") from the last message — fewer tokens, faster and cheaper. Never touches code.',
|
|
474
|
+
optVettingDesc: 'Complex answers (code/reasoning) get vetted by a second model and flagged on error. Costs one extra request per check.',
|
|
475
|
+
optRoutingDesc: 'How Freegate picks a provider: smart weighted, round-robin (even), or least-loaded.', optSaved: 'Saved! Restart Freegate to apply.',
|
|
476
|
+
crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management', topTitle: 'Control Panel',
|
|
424
477
|
kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
|
|
425
478
|
kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
|
|
426
479
|
cardSavings: 'Savings (vs paid APIs)', savings: 'Saved', tokens: 'tokens',
|
|
427
480
|
savingsBasis: 'estimate: input $3/M, output $15/M (representative)',
|
|
428
481
|
cardCache: 'Cache', cardTokens: 'Tokens', cardPool: 'Queue',
|
|
429
|
-
cardRpm: 'Requests per minute', cardRecent: 'Recent requests',
|
|
482
|
+
cardRpm: 'Requests per minute', cardRecent: 'Recent requests', cardSpark: 'Success today (hourly)',
|
|
430
483
|
cardCtx: 'Context (bottleneck)', cardTasks: 'Task categories (24h)',
|
|
431
484
|
loading: 'Loading...', noData: 'No data',
|
|
432
485
|
cacheHits: 'Hits', cacheMisses: 'Misses', cacheSize: 'Size', cacheRate: 'Rate',
|
|
433
486
|
poolAll: 'no queue', poolActive: 'active', poolWait: 'waiting',
|
|
434
|
-
searchProvider: 'Search provider...', searchModel: 'Search model...',
|
|
487
|
+
searchProvider: 'Search provider...', searchModel: 'Search model...', searchRecent: 'Search by model/provider...',
|
|
488
|
+
recentOk: 'OK', recentErr: 'Errors',
|
|
435
489
|
filterAll: 'All', filterUp: 'Up', filterLimit: 'Limit', filterError: 'Error', filterUnknown: 'Unknown',
|
|
436
490
|
thStatus: 'Status', thProvider: 'Provider', thLatency: 'Latency', thReliability: 'Reliability',
|
|
437
491
|
thLimit: 'Limit', thReason: 'Reason', dot: '—',
|
|
@@ -489,7 +543,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
489
543
|
|
|
490
544
|
// ── состояние клиента ──
|
|
491
545
|
var _health = {}, _limits = {}, _cache = null, _pool = {}, _statsData = null, _ctxSummary = null;
|
|
492
|
-
var _rpmData = [], _recent = [];
|
|
546
|
+
var _rpmData = [], _recent = [], _hourly = [];
|
|
493
547
|
var _models = [], _modelStats = null, _modelManager = null, _modelsFetchedAt = 0;
|
|
494
548
|
var _savings = null;
|
|
495
549
|
var _providerFilter = 'all', _providerQuery = '';
|
|
@@ -546,10 +600,12 @@ const HTML = `<!DOCTYPE html>
|
|
|
546
600
|
_pool = _statsData.pool || {};
|
|
547
601
|
_ctxSummary = _statsData.context_summary || null;
|
|
548
602
|
_savings = _statsData.savings || null;
|
|
603
|
+
_hourly = _statsData.hourly || [];
|
|
549
604
|
renderKpi();
|
|
550
605
|
renderAlerts();
|
|
551
606
|
renderOverviewCards();
|
|
552
607
|
renderRpm(_rpmData);
|
|
608
|
+
renderSparkline(_hourly);
|
|
553
609
|
renderRecent(_recent);
|
|
554
610
|
renderProvidersTable();
|
|
555
611
|
renderContext(_ctxSummary);
|
|
@@ -692,15 +748,70 @@ const HTML = `<!DOCTYPE html>
|
|
|
692
748
|
ctx.fillText('сейчас', w - 44, h - 3);
|
|
693
749
|
}
|
|
694
750
|
|
|
751
|
+
// ── Sparkline: успех/ошибки по часам (24ч) ──
|
|
752
|
+
function renderSparkline(hourly) {
|
|
753
|
+
var canvas = document.getElementById('sparkline');
|
|
754
|
+
if (!canvas) return;
|
|
755
|
+
var ctx = canvas.getContext('2d');
|
|
756
|
+
canvas.width = canvas.offsetWidth;
|
|
757
|
+
canvas.height = canvas.offsetHeight;
|
|
758
|
+
var w = canvas.width, h = canvas.height, padL = 8, padB = 18, padT = 8;
|
|
759
|
+
ctx.clearRect(0, 0, w, h);
|
|
760
|
+
if (!hourly || hourly.length === 0) {
|
|
761
|
+
ctx.fillStyle = '#5d6d7d'; ctx.font = '11px sans-serif';
|
|
762
|
+
ctx.fillText(t('noData'), padL + 4, h / 2);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
var data = hourly;
|
|
766
|
+
var maxVal = 1;
|
|
767
|
+
for (var i = 0; i < data.length; i++) if ((data[i].total || 0) > maxVal) maxVal = data[i].total;
|
|
768
|
+
var step = (w - padL - padB) / Math.max(data.length - 1, 1);
|
|
769
|
+
var barW = Math.max(2, Math.min(12, step - 3));
|
|
770
|
+
// колонки успеха (зелёные) + хвост ошибок (красный)
|
|
771
|
+
for (var i = 0; i < data.length; i++) {
|
|
772
|
+
var d = data[i];
|
|
773
|
+
var total = d.total || 0;
|
|
774
|
+
if (total === 0) continue;
|
|
775
|
+
var ok = d.ok || 0;
|
|
776
|
+
var fail = total - ok;
|
|
777
|
+
var x = padL + i * step;
|
|
778
|
+
var headH = (h - padT - padB);
|
|
779
|
+
var okH = (ok / maxVal) * headH;
|
|
780
|
+
var failH = (fail / maxVal) * headH;
|
|
781
|
+
var baseY = h - padB;
|
|
782
|
+
if (failH > 0) {
|
|
783
|
+
ctx.fillStyle = '#f87171';
|
|
784
|
+
ctx.fillRect(x, baseY - okH - failH, barW, failH);
|
|
785
|
+
}
|
|
786
|
+
ctx.fillStyle = '#4ade80';
|
|
787
|
+
ctx.fillRect(x, baseY - okH, barW, okH);
|
|
788
|
+
}
|
|
789
|
+
// подписи первого/последнего часа
|
|
790
|
+
ctx.fillStyle = '#5d6d7d'; ctx.font = '10px monospace';
|
|
791
|
+
ctx.fillText((data[0].hour || '').slice(8, 13), padL, h - 4);
|
|
792
|
+
var lastLbl = (data[data.length - 1].hour || '').slice(8, 13);
|
|
793
|
+
ctx.fillText(lastLbl, w - padB - 30, h - 4);
|
|
794
|
+
}
|
|
795
|
+
|
|
695
796
|
// ── недавние запросы ──
|
|
696
797
|
function renderRecent(recent) {
|
|
697
798
|
var table = document.getElementById('recentTable');
|
|
799
|
+
var q = (document.getElementById('recentSearch') ? (document.getElementById('recentSearch').value || '') : '').toLowerCase();
|
|
800
|
+
var stF = document.getElementById('recentStatusFilter') ? document.getElementById('recentStatusFilter').value : 'all';
|
|
801
|
+
var rows = recent.filter(function (r) {
|
|
802
|
+
if (stF === 'ok' && r.status !== 200) return false;
|
|
803
|
+
if (stF === 'err' && (r.status === 200 || r.status === undefined)) return false;
|
|
804
|
+
if (q && ((r.model || '') + ' ' + (r.provider || '')).toLowerCase().indexOf(q) < 0) return false;
|
|
805
|
+
return true;
|
|
806
|
+
});
|
|
698
807
|
var html = '<tr><th>' + t('thRecentTime') + '</th><th>' + t('thRecentModel') + '</th><th>' + t('thRecentProvider') + '</th><th>' + t('thRecentStatus') + '</th><th>' + t('thRecentLatency') + '</th></tr>';
|
|
699
|
-
|
|
700
|
-
var sc = r.status === 200 ? '
|
|
808
|
+
rows.forEach(function (r) {
|
|
809
|
+
var sc = r.status === 200 ? '#4ade80' : '#f87171';
|
|
701
810
|
html += '<tr><td>' + new Date(r.timestamp).toLocaleTimeString() + '</td><td>' + esc(r.model || '-') + '</td><td>' + esc(r.provider || '-') + '</td><td style="color:' + sc + '">' + (r.status || '-') + '</td><td>' + (r.latency || 0) + 'ms</td></tr>';
|
|
702
811
|
});
|
|
703
812
|
table.innerHTML = html;
|
|
813
|
+
var cnt = document.getElementById('recentCount');
|
|
814
|
+
if (cnt) cnt.textContent = rows.length + ' / ' + recent.length;
|
|
704
815
|
}
|
|
705
816
|
|
|
706
817
|
// ── ПРОВАЙДЕРЫ: фильтры/сортировка/таблица ──
|
|
@@ -924,7 +1035,41 @@ const HTML = `<!DOCTYPE html>
|
|
|
924
1035
|
_setupData = await res.json();
|
|
925
1036
|
renderSetup(_setupData);
|
|
926
1037
|
} catch (e) { console.error('Setup load error:', e); }
|
|
1038
|
+
loadOpts();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ── Опции оптимизации (compress/vetting/routing) ──
|
|
1042
|
+
async function loadOpts() {
|
|
1043
|
+
try {
|
|
1044
|
+
var res = await fetch(_api('/v1/config'));
|
|
1045
|
+
var c = await res.json();
|
|
1046
|
+
document.getElementById('optCompress').checked = !!c.compress.enabled;
|
|
1047
|
+
document.getElementById('optVetting').checked = !!c.vetting.enabled;
|
|
1048
|
+
document.getElementById('optRouting').value = c.routing.strategy || 'weighted';
|
|
1049
|
+
var note = document.getElementById('optSaveNote');
|
|
1050
|
+
if (note) note.textContent = '';
|
|
1051
|
+
} catch (e) { console.error('Config load error:', e); }
|
|
1052
|
+
}
|
|
1053
|
+
function saveOpts() {
|
|
1054
|
+
var body = {
|
|
1055
|
+
compress: { enabled: document.getElementById('optCompress').checked },
|
|
1056
|
+
vetting: { enabled: document.getElementById('optVetting').checked },
|
|
1057
|
+
routing: { strategy: document.getElementById('optRouting').value },
|
|
1058
|
+
};
|
|
1059
|
+
fetch(_api('/v1/config'), {
|
|
1060
|
+
method: 'POST',
|
|
1061
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1062
|
+
body: JSON.stringify(body),
|
|
1063
|
+
}).then(function (r) { return r.json(); }).then(function (d) {
|
|
1064
|
+
var note = document.getElementById('optSaveNote');
|
|
1065
|
+
if (note) note.textContent = d.ok ? (t('optSaved')) : (d.error || t('saveErr'));
|
|
1066
|
+
}).catch(function (e) {
|
|
1067
|
+
var note = document.getElementById('optSaveNote');
|
|
1068
|
+
if (note) note.textContent = e.message;
|
|
1069
|
+
});
|
|
927
1070
|
}
|
|
1071
|
+
document.getElementById('optCompress').addEventListener('change', saveOpts);
|
|
1072
|
+
document.getElementById('optVetting').addEventListener('change', saveOpts);
|
|
928
1073
|
function renderSetup(data) {
|
|
929
1074
|
var grid = document.getElementById('setup-grid');
|
|
930
1075
|
var html = '';
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// lib/doctor.js — автономная диагностика Freegate.
|
|
2
|
+
// Читает .env (ключи), валидирует их, смотрит модели из models-db.json и собирает
|
|
3
|
+
// «что включить». Не требует запущенного сервера. Используется `freegate doctor`.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const setup = require('./setup');
|
|
7
|
+
const { ModelDB } = require('./modeldb');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.join(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
// Провайдер, с которого удобно начинать (быстрый старт).
|
|
12
|
+
function easyStartKey() {
|
|
13
|
+
return { envVar: 'PROVIDER_OPENROUTER_APIKEY', name: 'OpenRouter', count: 15 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Сгруппировать провайдеры каталога по envVar (как в init).
|
|
17
|
+
function groupByEnv(catalog) {
|
|
18
|
+
const groups = new Map();
|
|
19
|
+
for (const [name, p] of Object.entries(catalog || {})) {
|
|
20
|
+
if (!p.envVar) continue;
|
|
21
|
+
if (!groups.has(p.envVar)) groups.set(p.envVar, []);
|
|
22
|
+
groups.get(p.envVar).push(name);
|
|
23
|
+
}
|
|
24
|
+
return groups;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadCatalog() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'providers.json'), 'utf8'));
|
|
30
|
+
} catch { return {}; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadModelDb() {
|
|
34
|
+
try {
|
|
35
|
+
const p = path.join(ROOT, 'models-db.json');
|
|
36
|
+
if (!fs.existsSync(p)) return {};
|
|
37
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
38
|
+
} catch { return {}; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statusOf(status) {
|
|
42
|
+
return status === 'active' ? 'up' : status === 'user-disabled' ? 'disabled' : status || 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Собрать снимок состояния: ключи, каталог, модели.
|
|
46
|
+
function snapshot({ catalog = loadCatalog(), modelDb = loadModelDb(), keysSource = null } = {}) {
|
|
47
|
+
const { keys, envPath } = keysSource || setup.readKeys();
|
|
48
|
+
const groups = groupByEnv(catalog);
|
|
49
|
+
const models = (modelDb.models && typeof modelDb.models === 'object') ? modelDb.models : {};
|
|
50
|
+
|
|
51
|
+
const keyState = {};
|
|
52
|
+
for (const [envVar, providers] of groups) {
|
|
53
|
+
const meta = setup.KEY_GROUPS[envVar];
|
|
54
|
+
keyState[envVar] = {
|
|
55
|
+
envVar,
|
|
56
|
+
name: meta ? (meta.name) : envVar,
|
|
57
|
+
url: meta ? meta.url : '',
|
|
58
|
+
key: (keys[envVar] || ''), // маскируем при выводе
|
|
59
|
+
providers,
|
|
60
|
+
count: providers.length,
|
|
61
|
+
hasKey: !!(keys[envVar] || '').trim(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Модели по статусу + источнику.
|
|
66
|
+
const byStatus = { active: 0, disabled: 0, unknown: 0 };
|
|
67
|
+
const bySource = {};
|
|
68
|
+
const dead = []; // disabled/мёртвые
|
|
69
|
+
const working = []; // active
|
|
70
|
+
for (const [key, m] of Object.entries(models)) {
|
|
71
|
+
const st = statusOf(m.status); // up/disabled/unknown
|
|
72
|
+
const bucket = st === 'up' ? 'active' : st === 'disabled' ? 'disabled' : 'unknown';
|
|
73
|
+
byStatus[bucket] = (byStatus[bucket] || 0) + 1;
|
|
74
|
+
const src = m.source || 'unknown';
|
|
75
|
+
bySource[src] = (bySource[src] || 0) + 1;
|
|
76
|
+
if (st === 'up') working.push(key);
|
|
77
|
+
else if (st === 'disabled' || st === 'unknown') dead.push(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { envPath, keyState, models, byStatus, bySource, working, dead, catalogCount: Object.keys(catalog).length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// «Что включить»: по аналогии с дашбордом и модель-менеджером.
|
|
84
|
+
// - disabledByUser: user-disabled, но free/локальные — «можно вернуть» (пользователь сам выключил).
|
|
85
|
+
// - deadOrUntested: dead/untested — проверь/удали.
|
|
86
|
+
// - paid: платные-подозрительные (маленький лимит): их держать выключенными.
|
|
87
|
+
function recommend(models) {
|
|
88
|
+
const all = Object.entries(models).map(([key, m]) => ({ key, model: m.model || key, source: m.source || '', status: m.status || 'unknown' }));
|
|
89
|
+
const disabledByUser = [];
|
|
90
|
+
const dead = [];
|
|
91
|
+
const paid = [];
|
|
92
|
+
const local = [];
|
|
93
|
+
for (const m of all) {
|
|
94
|
+
const isLocal = /local|ollama|lmstudio/i.test(m.source);
|
|
95
|
+
const isPaid = /deepseek|glm-5|ox-alpha|minimax-m2/i.test(m.model);
|
|
96
|
+
if (m.status === 'active') continue; // уже работает
|
|
97
|
+
if (m.status === 'dead' || m.status === 'untested') { dead.push(m.key); continue; }
|
|
98
|
+
if (isLocal) { local.push(m.key); continue; }
|
|
99
|
+
if (isPaid) { paid.push(m.key); continue; }
|
|
100
|
+
if (m.status === 'user-disabled') { disabledByUser.push(m.key); continue; }
|
|
101
|
+
// иначе — unknown, не трогаем
|
|
102
|
+
}
|
|
103
|
+
return { disabledByUser, dead, paid, local };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { snapshot, recommend, easyStartKey, groupByEnv };
|
package/lib/health.js
CHANGED
|
@@ -17,6 +17,51 @@ let recent = [];
|
|
|
17
17
|
let rpm = [];
|
|
18
18
|
let currentRpmMinute = 0;
|
|
19
19
|
|
|
20
|
+
// Почасовое кольцо успеха (24 бина по UTC-часу). Для sparkline в дашборде.
|
|
21
|
+
// Каждый бин: { hour: 'YYYY-MM-DDTHH', ok, total }. Пустые часы (ещё не наступили) — total:0.
|
|
22
|
+
const HOURLY_MAX = 24;
|
|
23
|
+
let hourly = [];
|
|
24
|
+
let hourlyDay = ''; // текущий день контекста (dayKey), чтобы не путать часы между днями
|
|
25
|
+
|
|
26
|
+
function dayKey() {
|
|
27
|
+
return new Date().toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hourKey() {
|
|
31
|
+
return new Date().toISOString().slice(0, 13); // 'YYYY-MM-DDTHH'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ensureHourly() {
|
|
35
|
+
const dk = dayKey();
|
|
36
|
+
if (hourlyDay !== dk || hourly.length === 0) {
|
|
37
|
+
hourlyDay = dk;
|
|
38
|
+
hourly = [];
|
|
39
|
+
// Заполняем прошедшие часы этого UTC-дня нулями + текущий.
|
|
40
|
+
const nowHour = new Date().getUTCHours();
|
|
41
|
+
for (let h = 0; h <= nowHour; h++) {
|
|
42
|
+
hourly.push({ hour: dk + 'T' + String(h).padStart(2, '0'), ok: 0, total: 0 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordHourly(success) {
|
|
48
|
+
ensureHourly();
|
|
49
|
+
const hk = hourKey();
|
|
50
|
+
const last = hourly[hourly.length - 1];
|
|
51
|
+
if (last && last.hour !== hk) {
|
|
52
|
+
// Новый час — добавляем бин, держим максимум HOURLY_MAX.
|
|
53
|
+
hourly.push({ hour: hk, ok: 0, total: 0 });
|
|
54
|
+
if (hourly.length > HOURLY_MAX) hourly.shift();
|
|
55
|
+
}
|
|
56
|
+
const bin = hourly[hourly.length - 1];
|
|
57
|
+
if (bin) { bin.total++; if (success) bin.ok++; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getHourly() {
|
|
61
|
+
ensureHourly();
|
|
62
|
+
return hourly;
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
function loadState() {
|
|
21
66
|
try {
|
|
22
67
|
const data = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
@@ -129,6 +174,7 @@ function classifyErrorMs(statusCode, opts) {
|
|
|
129
174
|
|
|
130
175
|
function recordRequest(providerKey, success, error = null) {
|
|
131
176
|
recordRpm();
|
|
177
|
+
recordHourly(success);
|
|
132
178
|
stats.totalRequests++;
|
|
133
179
|
const today = new Date().toISOString().slice(0, 10);
|
|
134
180
|
// Error counter is all-time by default; reset it once per UTC day (like reliability).
|
|
@@ -233,6 +279,7 @@ function warmBanditPriors(enabledKeys, bucketKeys) {
|
|
|
233
279
|
|
|
234
280
|
// Auto-save every 30 seconds
|
|
235
281
|
const _saveTimer = setInterval(saveState, 30000);
|
|
282
|
+
if (_saveTimer.unref) _saveTimer.unref();
|
|
236
283
|
|
|
237
284
|
function _stopTimers() { clearInterval(_saveTimer); }
|
|
238
285
|
|
|
@@ -242,6 +289,7 @@ module.exports = {
|
|
|
242
289
|
getHealth, getStats, getCircuitBreakers,
|
|
243
290
|
recordRecent, recordRpm, getRecent, getRpm,
|
|
244
291
|
getDailyUsage, getReliability,
|
|
292
|
+
getHourly,
|
|
245
293
|
recordSelection, getLastSelection,
|
|
246
294
|
getBandit, recordBandit, warmBanditPriors,
|
|
247
295
|
getContextStats: () => contextStats,
|
package/lib/modelmanager.js
CHANGED
|
@@ -26,6 +26,13 @@ function inferSource(providerEntry) {
|
|
|
26
26
|
const env = providerEntry.envVar || '';
|
|
27
27
|
if (env.includes('OPENROUTER')) return 'openrouter';
|
|
28
28
|
if (env.includes('HF')) return 'huggingface';
|
|
29
|
+
if (env.includes('SAMBANOVA')) return 'sambanova';
|
|
30
|
+
if (env.includes('SILICONFLOW')) return 'siliconflow';
|
|
31
|
+
if (env.includes('DEEPINFRA')) return 'deepinfra';
|
|
32
|
+
if (env.includes('HYPERBOLIC')) return 'hyperbolic';
|
|
33
|
+
if (env.includes('COHERE')) return 'cohere';
|
|
34
|
+
if (env.includes('LLM7')) return 'llm7';
|
|
35
|
+
if (env.includes('NARA')) return 'nara';
|
|
29
36
|
const ep = providerEntry.endpoint || '';
|
|
30
37
|
if (ep.includes('api.groq.com')) return 'groq';
|
|
31
38
|
if (ep.includes('api.mistral.ai')) return 'mistral';
|
|
@@ -33,6 +40,13 @@ function inferSource(providerEntry) {
|
|
|
33
40
|
if (ep.includes('api.cerebras.ai')) return 'cerebras';
|
|
34
41
|
if (ep.includes('api.deepseek.com')) return 'deepseek';
|
|
35
42
|
if (ep.includes('integrate.api.nvidia')) return 'nim';
|
|
43
|
+
if (ep.includes('api.sambanova.ai')) return 'sambanova';
|
|
44
|
+
if (ep.includes('api.siliconflow.com')) return 'siliconflow';
|
|
45
|
+
if (ep.includes('api.deepinfra.com')) return 'deepinfra';
|
|
46
|
+
if (ep.includes('api.hyperbolic.xyz')) return 'hyperbolic';
|
|
47
|
+
if (ep.includes('api.cohere.com')) return 'cohere';
|
|
48
|
+
if (ep.includes('api.llm7.io')) return 'llm7';
|
|
49
|
+
if (ep.includes('router.bynara.id')) return 'nara';
|
|
36
50
|
return 'unknown';
|
|
37
51
|
}
|
|
38
52
|
|
package/lib/modelscan.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (только :free), groq, cerebras, gemini (free tier), nim (малый лимит, но
|
|
10
10
|
// стабильно бесплатный), huggingface. Каталог расширяется вручную через
|
|
11
11
|
// config.json/providers.json.
|
|
12
|
-
const SOURCES = ['openrouter', 'huggingface', 'groq', 'gemini', 'cerebras', 'nim'];
|
|
12
|
+
const SOURCES = ['openrouter', 'huggingface', 'groq', 'gemini', 'cerebras', 'nim', 'sambanova', 'siliconflow', 'deepinfra', 'hyperbolic', 'cohere'];
|
|
13
13
|
|
|
14
14
|
// Endpoints списков моделей и chat-completions по источникам.
|
|
15
15
|
const SOURCE_META = {
|
|
@@ -21,6 +21,11 @@ const SOURCE_META = {
|
|
|
21
21
|
cerebras: { list: 'https://api.cerebras.ai/v1/models', chat: 'https://api.cerebras.ai/v1/chat/completions', envVar: 'PROVIDER_CEREBRAS_APIKEY', dailyLimit: 1000 },
|
|
22
22
|
deepseek: { list: 'https://api.deepseek.com/models', chat: 'https://api.deepseek.com/chat/completions', envVar: 'PROVIDER_DEEPSEEK_APIKEY', dailyLimit: 1000 },
|
|
23
23
|
nim: { list: 'https://integrate.api.nvidia.com/v1/models', chat: 'https://integrate.api.nvidia.com/v1/chat/completions', envVar: 'PROVIDER_NIM_APIKEY', dailyLimit: 40 },
|
|
24
|
+
sambanova: { list: 'https://api.sambanova.ai/v1/models', chat: 'https://api.sambanova.ai/v1/chat/completions', envVar: 'PROVIDER_SAMBANOVA_APIKEY', dailyLimit: 100000 },
|
|
25
|
+
siliconflow: { list: 'https://api.siliconflow.com/v1/models', chat: 'https://api.siliconflow.com/v1/chat/completions', envVar: 'PROVIDER_SILICONFLOW_APIKEY', dailyLimit: 100000 },
|
|
26
|
+
deepinfra: { list: 'https://api.deepinfra.com/v1/openai/models', chat: 'https://api.deepinfra.com/v1/openai/chat/completions', envVar: 'PROVIDER_DEEPINFRA_APIKEY', dailyLimit: 100000 },
|
|
27
|
+
hyperbolic: { list: 'https://api.hyperbolic.xyz/v1/models', chat: 'https://api.hyperbolic.xyz/v1/chat/completions', envVar: 'PROVIDER_HYPERBOLIC_APIKEY', dailyLimit: 100000 },
|
|
28
|
+
cohere: { list: 'https://api.cohere.com/compatibility/v1/models', chat: 'https://api.cohere.com/compatibility/v1/chat/completions', envVar: 'PROVIDER_COHERE_APIKEY', dailyLimit: 100000 },
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
// Мусор: эмбеддинги, реранки, аудио/речь/музыка, guardrail, картинки/видео,
|
|
@@ -101,7 +106,7 @@ const ADAPTERS = {
|
|
|
101
106
|
};
|
|
102
107
|
|
|
103
108
|
// Нативные OpenAI-совместимые списки: один паттерн на все источники.
|
|
104
|
-
for (const name of ['groq', 'mistral', 'gemini', 'cerebras', 'deepseek', 'nim']) {
|
|
109
|
+
for (const name of ['groq', 'mistral', 'gemini', 'cerebras', 'deepseek', 'nim', 'sambanova', 'siliconflow', 'deepinfra', 'hyperbolic', 'cohere']) {
|
|
105
110
|
ADAPTERS[name] = async (ctx) => {
|
|
106
111
|
const meta = SOURCE_META[name];
|
|
107
112
|
const key = ctx.keys[name] || process.env[meta.envVar] || '';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// lib/onboarding.js — готовые сниппеты подключения и профили моделей.
|
|
2
|
+
// Чистые функции: не трогают диск, возвращают текст конфига. Для doctor/init.
|
|
3
|
+
function snippetCursor(baseUrl, apiKey) {
|
|
4
|
+
return [
|
|
5
|
+
'// Cursor → Settings → Models',
|
|
6
|
+
'// Включи провайдер «OpenAI-compatible» и впиши:',
|
|
7
|
+
'Model: openai/gpt-oss-120b (или любая из каталога)',
|
|
8
|
+
'Base URL: ' + baseUrl + '/v1',
|
|
9
|
+
'API Key: ' + apiKey,
|
|
10
|
+
'Add model: столько бесплатных моделей, сколько захочешь.',
|
|
11
|
+
'',
|
|
12
|
+
'Совет: открыть дашборд → ' + baseUrl + ' (пароль = API Key).',
|
|
13
|
+
].join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function snippetOpencode(baseUrl, apiKey) {
|
|
17
|
+
return [
|
|
18
|
+
'// opencode — добавь провайдер в opencode.json',
|
|
19
|
+
'{',
|
|
20
|
+
' "$schema": "https://opencode.ai/config.json",',
|
|
21
|
+
' "provider": {',
|
|
22
|
+
' "freegate": {',
|
|
23
|
+
' "npm": "@ai-sdk/openai-compatible",',
|
|
24
|
+
' "name": "Freegate",',
|
|
25
|
+
' "options": {',
|
|
26
|
+
' "baseURL": "' + baseUrl + '/v1",',
|
|
27
|
+
' "apiKey": "' + apiKey + '"',
|
|
28
|
+
' }',
|
|
29
|
+
' }',
|
|
30
|
+
' },',
|
|
31
|
+
' "model": "freegate/openai/gpt-oss-120b"',
|
|
32
|
+
'}',
|
|
33
|
+
'',
|
|
34
|
+
'Затем: npx opencode (выбери модель freegate).',
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function snippetCline(baseUrl, apiKey) {
|
|
39
|
+
return [
|
|
40
|
+
'// Cline (VS Code) → Settings → API Provider: OpenAI Compatible',
|
|
41
|
+
'Base URL: ' + baseUrl + '/v1',
|
|
42
|
+
'API Key: ' + apiKey,
|
|
43
|
+
'Model: openai/gpt-oss-120b',
|
|
44
|
+
'',
|
|
45
|
+
'Убедись, что в Cline включён "OpenAI Compatible" провайдер.',
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Профили: пресеты под разные роли (каких моделей больше в роутинге).
|
|
50
|
+
function profiles() {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
id: 'chat',
|
|
54
|
+
name: 'Чат-ассистент',
|
|
55
|
+
hint: 'Общие вопросы, переписка. Категория chat.',
|
|
56
|
+
prefer: ['general', 'chat'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'coder',
|
|
60
|
+
name: 'Кодер',
|
|
61
|
+
hint: 'Код, рефакторинг, тесты. Категория coding.',
|
|
62
|
+
prefer: ['coding', 'reasoning'],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'designer',
|
|
66
|
+
name: 'Дизайнер',
|
|
67
|
+
hint: 'UI/UX, фронтенд. Категория design.',
|
|
68
|
+
prefer: ['coding', 'design'],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'researcher',
|
|
72
|
+
name: 'Исследователь',
|
|
73
|
+
hint: 'Поиск фактов, веб-поиск. Категория search.',
|
|
74
|
+
prefer: ['search', 'reasoning'],
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { snippetCursor, snippetOpencode, snippetCline, profiles };
|