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/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>Панель управления</div>
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="card" style="margin-top:16px;"><h2 data-i18n="cardRpm"></h2><canvas id="rpmChart"></canvas></div>
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
- crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate',
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
- crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management',
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
- recent.forEach(function (r) {
700
- var sc = r.status === 200 ? 'var(--ok)' : 'var(--err)';
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,
@@ -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 };