freegate 0.6.21 → 0.6.23

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/README.md CHANGED
@@ -37,6 +37,7 @@ sees your keys or conversations.**
37
37
  |---|---|
38
38
  | 🔀 **Auto-failover** | 50+ providers in one chain. Provider down? The next one answers. |
39
39
  | 🤖 **Self-managing models** | Auto-discovers new free models, tests them, adds working ones, disables dead ones — built-in scheduler, always-fresh model base. |
40
+ | 🎛️ **Full manual control** | You decide what runs: enable/disable any provider or model, set your own keys (free **or paid**), and pick the routing mode. No magic hidden from you. |
40
41
  | 🗂️ **Model database** | Structured passport per model (score, latency, context window, history) + sorting: best models get routing priority. |
41
42
  | 🏷️ **Model categories** | reasoning / coding / general / vision / local — the right model for the right job. |
42
43
  | 🖼️ **Two-stage vision** | Screenshot → vision model reads it → coding model answers the fix. |
@@ -219,12 +220,37 @@ PROVIDER_ZAI_APIKEY=... # ZAI
219
220
  **Providers** — catalog in `providers.json` (18 models). Add your own:
220
221
  put it in `config.json` → `providers` (same format as `providers.json`).
221
222
 
223
+ **Paid providers + free↔paid routing** — Freegate normally uses only free models, but
224
+ honors your own paid keys. Mark a provider paid in `config.json` → `providers` with
225
+ `"paid": true` (or `"free": false`) and add its key in `.env`. Then choose how it's used:
226
+
227
+ ```json
228
+ { "routing": { "preference": "free-first" } }
229
+ ```
230
+
231
+ - `free-first` (default) — free base in priority, your paid keys only as backup.
232
+ - `paid-first` — your paid keys take priority, free as fallback.
233
+ - `paid-fallback` — free by default; paid are touched only when free fail.
234
+
235
+ > Example: put your DeepSeek or any OpenAI-compatible paid key under free gate,
236
+ > and switch to `paid-fallback` so expensive calls only kick in when free providers are
237
+ > down, not for every request.
238
+
239
+ **Auto-update on the fly** — Freegate can rebase itself without you touching a chat:
240
+ ```json
241
+ { "autoUpdate": { "enabled": true, "intervalHours": 12 } }
242
+ ```
243
+ Every interval it does `git fetch` + fast-forward `pull` on its own folder, then restarts
244
+ the launchd service. Safe: it skips if the working tree is dirty (your local edits win),
245
+ and never touches `.env`/`config.json` (gitignored).
246
+
222
247
  ## CLI commands
223
248
 
224
249
  ```bash
225
250
  npx freegate init # create config
226
251
  npx freegate init -i # interactive wizard (keys, password)
227
252
  npx freegate doctor # diagnostics: keys, models, "what to check"
253
+ npx freegate diag # report: success rate, error classes, provider switches
228
254
  npx freegate start # start proxy
229
255
  npx freegate status # diagnostics: providers, limits, errors
230
256
  npx freegate test # verify it works
package/README.ru.md CHANGED
@@ -37,6 +37,7 @@ Freegate крутится **на твоей машине** (`http://localhost:40
37
37
  |---|---|
38
38
  | 🔀 **Автопереключение** | 50+ провайдеров в одной цепочке. Провайдер упал? Следующий уже отвечает. |
39
39
  | 🤖 **Автоуправление моделями** | Сам находит новые бесплатные модели, тестирует, добавляет рабочие, отключает мёртвые — встроенный планировщик, всегда актуальная база. |
40
+ | 🎛️ **Полный ручной контроль** | Ты решаешь, что работает: включай/отключай любой провайдер или модель, подставляй свои ключи (бесплатные **или платные**) и выбирай режим роутинга. Никакой магии за твоей спиной. |
40
41
  | 🗂️ **База моделей** | Структурированный паспорт каждой модели (скор, латентность, окно, история) + сортировка: лучшие модели получают приоритет в роутинге. |
41
42
  | 🏷️ **Категории моделей** | reasoning / coding / general / vision / local — правильная модель для каждой задачи. |
42
43
  | 🖼️ **Vision-конвейер** | Скриншот → vision-модель читает → кодинг-модель отвечает на вопрос. |
@@ -223,6 +224,31 @@ PROVIDER_ZAI_APIKEY=... # ZAI
223
224
  **Провайдеры** — каталог в `providers.json` (18 моделей). Добавить свой:
224
225
  впиши его в `config.json` → `providers` (формат как в `providers.json`).
225
226
 
227
+ **Платные провайдеры + маршрутизация free↔paid.** По умолчанию Freegate использует
228
+ только бесплатные модели, но уважает твои платные ключи. Пометь провайдера платным
229
+ в `config.json` → `providers` флагом `"paid": true` (или `"free": false`) и добавь его
230
+ ключ в `.env`. Затем выбери, как его использовать:
231
+
232
+ ```json
233
+ { "routing": { "preference": "free-first" } }
234
+ ```
235
+
236
+ - `free-first` (по умолчанию) — бесплатная база в приоритете, твои платные ключи как запас.
237
+ - `paid-first` — твои платные ключи в приоритете, бесплатные как fallback.
238
+ - `paid-fallback` — по умолчанию бесплатные; платные трогаем, только когда бесплатные упали.
239
+
240
+ > Пример: добавь свой ключ DeepSeek или любой OpenAI-совместимый под Freegate и переключись
241
+ > на `paid-fallback` — дорогие вызовы будут идти только когда бесплатные провайдеры не
242
+ > справились, а не на каждый запрос.
243
+
244
+ **Автообновление на лету** — Freegate может обновляться сам, без твоего участия в чате:
245
+ ```json
246
+ { "autoUpdate": { "enabled": true, "intervalHours": 12 } }
247
+ ```
248
+ Каждый интервал он делает `git fetch` + `pull` (fast-forward) в своей папке и перезапускает
249
+ сервис launchd. Безопасно: пропускается, если рабочее дерево грязное (твои локальные правки
250
+ важнее), и никогда не трогает `.env`/`config.json` (gitignored).
251
+
226
252
  **Методолог** — в `config.json`:
227
253
 
228
254
  ```json
@@ -247,6 +273,7 @@ PROVIDER_ZAI_APIKEY=... # ZAI
247
273
  npx freegate init # создать конфиг
248
274
  npx freegate init -i # интерактивный мастер (ключи, пароль)
249
275
  npx freegate doctor # диагностика: ключи, модели, «что проверить»
276
+ npx freegate diag # отчёт: успешность, классы ошибок, смена провайдеров
250
277
  npx freegate start # запустить прокси
251
278
  npx freegate status # диагностика: провайдеры, лимиты, ошибки
252
279
  npx freegate test # проверить, что работает
package/bin/freegate.js CHANGED
@@ -366,12 +366,49 @@ WantedBy=default.target
366
366
  } else if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
367
367
  const pkg = require(path.join(ROOT, 'package.json'));
368
368
  console.log(pkg.version);
369
+ } else if (cmd === 'diag' || cmd === 'diagnose') {
370
+ const http = require('http');
371
+ const base = `http://127.0.0.1:${process.env.PORT || 4000}`;
372
+ const diag = require(path.join(ROOT, 'lib', 'diag'));
373
+ const g = (p) => new Promise((resolve) => {
374
+ http.get(base + p, (res) => {
375
+ let d = ''; res.on('data', (c) => d += c);
376
+ res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(null); } });
377
+ }).on('error', () => resolve(null));
378
+ });
379
+ (async () => {
380
+ const [stats, recent] = await Promise.all([g('/v1/stats'), g('/v1/recent')]);
381
+ if (!stats) { console.log('❌ Прокси не отвечает на ' + base + ' (запусти `npx freegate start`)'); process.exit(1); }
382
+ const r = diag.buildReport(stats, recent?.data || []);
383
+ const pct = (n, d) => d > 0 ? Math.round((n / d) * 100) + '%' : '—';
384
+ console.log('🔬 Freegate diag (v' + r.version + ', uptime ' + Math.round((r.uptime || 0) / 60) + 'м)');
385
+ console.log('────────────────────────────────');
386
+ console.log('Сегодня: ' + r.today.requests + ' запросов → ' + r.today.success + ' успех, ' + r.today.failed + ' ошибок (' + (r.today.successRate ?? '—') + '%)');
387
+ console.log('Провайдеры: ' + r.providers.up + ' up · ' + r.providers.ratelimited + ' ratelimited · ' + r.providers.down + ' error (из ' + r.providers.total + ')');
388
+ console.log('Недавние: ' + r.recent.total + ' записей (' + r.recent.ok + ' ок, ' + r.recent.err + ' err, ' + r.recent.cached + ' из кэша), активно провайдеров: ' + r.recent.providersActive);
389
+ console.log('Контекст: ' + r.context.status + ' · компакций ' + r.context.compactCount + ' · over-window ' + r.context.overWindow + ' · апгрейдов ' + r.context.upgradedCount);
390
+ console.log('');
391
+ console.log('Ошибки по классам:');
392
+ for (const [cls, cnt] of Object.entries(r.classes)) {
393
+ console.log(' ' + (cnt > 0 ? '🟠' : ' ') + ' ' + cls.padEnd(9) + String(cnt).padStart(4) + ' ' + diag.LABELS[cls]);
394
+ if (cnt > 0 && diag.FIXES[cls]) console.log(' └→ ' + diag.FIXES[cls]);
395
+ }
396
+ console.log('');
397
+ const topErrors = Object.entries(r.errors).sort((a, b) => b[1].errors - a[1].errors).slice(0, 10);
398
+ if (topErrors.length) {
399
+ console.log('Топ провайдеров по ошибкам:');
400
+ for (const [k, v] of topErrors) {
401
+ console.log(' ' + String(v.errors).padStart(3) + ' ' + k.padEnd(34) + ' [' + v.status + '] ' + (v.reason || '').slice(0, 40));
402
+ }
403
+ }
404
+ })();
369
405
  } else {
370
406
  console.log('Freegate — бесплатный LLM-прокси с failover');
371
407
  console.log('Команды:');
372
408
  console.log(' npx freegate init интерактивная настройка (quick/full режим, пароль)');
373
409
  console.log(' npx freegate start запустить прокси');
374
410
  console.log(' npx freegate status диагностика: провайдеры, лимиты, ошибки');
411
+ console.log(' npx freegate diag отчёт: успешность, классы ошибок, смена провайдеров');
375
412
  console.log(' npx freegate test проверить, что прокси работает');
376
413
  console.log(' npx freegate dashboard открыть дашборд');
377
414
  console.log(' npx freegate install-service автозапуск при старте системы');
@@ -29,12 +29,17 @@
29
29
  "complexityOnly": true
30
30
  },
31
31
  "routing": {
32
- "strategy": "weighted"
32
+ "strategy": "weighted",
33
+ "preference": "free-first"
33
34
  },
34
35
  "compress": {
35
36
  "enabled": false,
36
37
  "minLen": 60
37
38
  },
39
+ "autoUpdate": {
40
+ "enabled": false,
41
+ "intervalHours": 12
42
+ },
38
43
  "providers": {
39
44
  "or-nemotron-550b": {
40
45
  "endpoint": "https://openrouter.ai/api/v1/chat/completions",
@@ -0,0 +1,64 @@
1
+ // Автообновление Freegate на лету: периодически проверяет новую версию
2
+ // (git fetch) и при наличии апстрима делает fast-forward pull + перезапускает
3
+ // сервис (launchd), не требуя действия от пользователя. Никогда не трогает
4
+ // .env/config.json (gitignored) и не переживает локальные незакоммиченные
5
+ // правки: при грязном рабочем дереве pull пропускается.
6
+ const { execFile } = require('child_process');
7
+
8
+ const DEFAULT_INTERVAL_HOURS = 12;
9
+ const AUTO_UPDATE = () => {
10
+ const cfg = {};
11
+ try { Object.assign(cfg, require('../config.json').autoUpdate); } catch {}
12
+ return {
13
+ enabled: process.env.FREEGATE_AUTO_UPDATE ? process.env.FREEGATE_AUTO_UPDATE === '1' : !!cfg.enabled,
14
+ intervalMs: cfg.intervalHours ? cfg.intervalHours * 3600e3 : cfg.intervalMs || (DEFAULT_INTERVAL_HOURS * 3600e3),
15
+ };
16
+ };
17
+
18
+ function git(root, args) {
19
+ return new Promise((resolve) => {
20
+ execFile('git', args, { cwd: root, timeout: 30000 }, (err, stdout, stderr) => {
21
+ if (err) resolve({ ok: false, out: (stderr || stdout || '').trim() });
22
+ else resolve({ ok: true, out: (stdout || '').trim() });
23
+ });
24
+ });
25
+ }
26
+
27
+ async function checkForUpdate(root, log) {
28
+ const cfg = AUTO_UPDATE();
29
+ if (!cfg.enabled) return { skipped: true };
30
+ const fetch = await git(root, ['fetch', 'origin']);
31
+ if (!fetch.ok) return { ok: false, reason: 'fetch: ' + fetch.out };
32
+ // Рабочее дерево должно быть чистым, иначе pull может конфликтовать.
33
+ const status = await git(root, ['status', '--porcelain']);
34
+ if (status.out) return { ok: false, reason: 'грязное дерево (' + status.out.split('\n').length + ' файлов)' };
35
+ const diff = await git(root, ['rev-list', '--count', 'HEAD..@{u}']);
36
+ if (!diff.ok || diff.out.trim() === '0') return { ok: false, reason: 'нет новых коммитов' };
37
+ const ahead = Number(diff.out.trim());
38
+ const pull = await git(root, ['pull', '--ff-only', 'origin']);
39
+ if (!pull.ok) return { ok: false, reason: 'pull: ' + pull.out };
40
+ log('autoUpdate: pulled ' + ahead + ' commits, restarting service');
41
+ return { ok: true, pulled: ahead };
42
+ }
43
+
44
+ function restartService() {
45
+ const { execFileSync } = require('child_process');
46
+ try { execFileSync('launchctl', ['kickstart', '-k', 'gui/' + process.getuid() + '/com.free-llm-proxy']); }
47
+ catch (e) { try { execFileSync('kill', ['-HUP', String(process.pid)]); } catch {} }
48
+ }
49
+
50
+ function startAutoUpdate({ root, log }) {
51
+ const cfg = AUTO_UPDATE();
52
+ if (!cfg.enabled) return;
53
+ const tick = () => {
54
+ checkForUpdate(root, log).then((r) => {
55
+ if (r.ok) {
56
+ setTimeout(() => { restartService(); }, 800);
57
+ }
58
+ }).catch(() => {});
59
+ };
60
+ setTimeout(() => tick(), 60e3); // первый цикл через 1 мин, дальше по расписанию
61
+ setInterval(tick, cfg.intervalMs);
62
+ }
63
+
64
+ module.exports = { startAutoUpdate, checkForUpdate, DEFAULT_INTERVAL_HOURS };
package/lib/compactor.js CHANGED
@@ -17,14 +17,19 @@ 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 = 1.5; // heuristic chars→tokens, DOUBLY conservative: real sessions
21
- // (with tool outputs, files, big system prompts) average ~0.75
22
- // chars/token, not ~4. Using 1.5 keeps us from compacting too late.
23
-
24
- // Reliability-ordered long-context summarizers (OpenRouter + HF).
20
+ const CHARS_PER_TOKEN = 1.0; // heuristic chars→tokens. Прежнее 1.5 ДВАЖДЫ завышало
21
+ // оценку (реально ~0.75-0.8 chars/token) компакции
22
+ // срабатывали почти на каждый второй запрос (53%),
23
+ // каждый — доп. LLM-вызов = медленно + риск «замирания».
24
+ // 1.0 всё ещё с запасом, но почти вдвое меньше лишних
25
+ // компакций. keep-recent и оконные пороги не меняем.
26
+
27
+ // Reliability-ordered long-context summarizers. Порядок важен: getSummary идёт
28
+ // по списку и ждёт ПЕРВЫЙ отвечающий (до 30с). Медленные/непроверенные в начале
29
+ // = «думает и замирает» (компакция тормозит каждый вызов). Бытрые и надёжные вперёд.
25
30
  const SUMMARIZERS = [
26
- 'or-nemotron-35', 'or-dots-3', 'or-minimax-m2-7-free',
27
- 'or-lfm', 'deepseek',
31
+ 'or-dots-3', 'deepseek', 'or-minimax-m2-7-free',
32
+ 'or-lfm', 'or-nemotron-35',
28
33
  ];
29
34
 
30
35
  // Summary cache: hash of compacted messages → summary string.
@@ -205,7 +210,7 @@ async function summarizeViaChain(messages) {
205
210
  { role: 'user', content: 'Сожми следующий диалог:\n\n' + textToSummarize },
206
211
  ],
207
212
  max_tokens: SUMMARY_MAX_TOKENS,
208
- }, 30000, 1);
213
+ }, 15000, 1); // 15с на summarizer — быстрое переползание, без «замирания»
209
214
  const summary = result.data?.choices?.[0]?.message?.content;
210
215
  if (summary && summary.trim().length >= 10) {
211
216
  return summary.trim();
package/lib/dashboard.js CHANGED
@@ -94,6 +94,7 @@ const HTML = `<!DOCTYPE html>
94
94
  color: var(--accent-dim); font-weight: 700; font-size: 13px; border: 1px solid var(--border); }
95
95
  .side-user .info .role { font-size: 11px; color: var(--muted); }
96
96
  .side-user .info .email { font-size: 12.5px; font-weight: 600; }
97
+ .side-user .info .version { font-size: 10.5px; font-weight: 500; color: var(--accent-dim); margin-left: 2px; }
97
98
 
98
99
  /* ── Контент ── */
99
100
  #main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@@ -197,7 +198,12 @@ const HTML = `<!DOCTYPE html>
197
198
  .mini-btn { padding: 6px 11px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--panel2);
198
199
  color: var(--text); font-size: 12px; cursor: pointer; transition: all .15s ease; font-weight: 500; }
199
200
  .mini-btn:hover { border-color: var(--accent); color: var(--accent-ink); }
200
- .mini-btn.off { color: var(--warn); }
201
+ .mini-btn.on { color: var(--ok); border-color: rgba(74,222,128,.35); }
202
+ .mini-btn.off { color: var(--warn); border-color: rgba(251,191,36,.35); }
203
+ .test-res { font-size: 11px; margin-left: 6px; min-width: 56px; }
204
+ .test-res.testing { color: var(--accent-dim); }
205
+ .test-res.ok { color: var(--ok); }
206
+ .test-res.fail { color: var(--err); }
201
207
 
202
208
  /* ── Рекомендации ── */
203
209
  .rec { font-size: 12.5px; margin-bottom: 7px; line-height: 1.6; }
@@ -221,6 +227,10 @@ const HTML = `<!DOCTYPE html>
221
227
  .setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; }
222
228
  .key-card { background: var(--panel); border-radius: var(--radius); padding: 18px 20px; border: 1px solid var(--border); }
223
229
  .key-card h3 { color: var(--text); font-size: 15px; margin-bottom: 6px; font-weight: 700; }
230
+ .key-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
231
+ .key-head h3 { margin: 0; }
232
+ .key-actions { display: flex; gap: 8px; margin-top: 10px; }
233
+ .key-link { color: var(--accent-ink); text-decoration: none; }
224
234
  .key-card .desc { color: var(--muted); font-size: 12.5px; margin-bottom: 10px; line-height: 1.5; }
225
235
  .key-card ol { color: var(--muted); font-size: 12px; padding-left: 18px; margin-bottom: 10px; }
226
236
  .key-card li { margin-bottom: 4px; }
@@ -276,6 +286,7 @@ const HTML = `<!DOCTYPE html>
276
286
  <div class="tab" data-i18n="tabProviders" onclick="showTab('providers')"></div>
277
287
  <div class="tab" data-i18n="tabModels" onclick="showTab('models')"></div>
278
288
  <div class="tab" data-i18n="tabContext" onclick="showTab('context')"></div>
289
+ <div class="tab" data-i18n="tabDiag" onclick="showTab('diag')"></div>
279
290
  <div class="tab" data-i18n="tabSetup" onclick="showTab('setup')"></div>
280
291
  </nav>
281
292
 
@@ -283,8 +294,8 @@ const HTML = `<!DOCTYPE html>
283
294
  <div class="side-user">
284
295
  <div class="side-avatar">F</div>
285
296
  <div class="info">
286
- <div class="email">Freegate</div>
287
- <div class="role">Admin</div>
297
+ <div class="email">Freegate <span id="fgVersion" class="version"></span></div>
298
+ <div class="role" data-i18n="sideRole"></div>
288
299
  </div>
289
300
  </div>
290
301
  </div>
@@ -381,6 +392,15 @@ const HTML = `<!DOCTYPE html>
381
392
  </div>
382
393
  </section>
383
394
 
395
+ <!-- ═══ ДИАГНОСТИКА ═══ -->
396
+ <section id="panel-diag" class="panel">
397
+ <div id="diagSummary" class="grid"></div>
398
+ <div class="card" style="margin-top:16px;">
399
+ <h2 data-i18n="diagErrors"></h2>
400
+ <div class="table-wrap"><table id="diagErrorsTable"></table></div>
401
+ </div>
402
+ </section>
403
+
384
404
  <!-- ═══ НАСТРОЙКИ ═══ -->
385
405
  <section id="panel-setup" class="panel">
386
406
  <div style="margin-bottom: 16px;">
@@ -403,6 +423,15 @@ const HTML = `<!DOCTYPE html>
403
423
  <span class="opt-hint" id="optRoutingState"></span>
404
424
  </div>
405
425
  <div class="opt-desc" data-i18n="optRoutingDesc"></div>
426
+ <div class="opt-row"><span data-i18n="optPreference"></span>
427
+ <select id="optPreference" class="filter" onchange="saveOpts()">
428
+ <option value="free-first" data-i18n="prefFreeFirst"></option>
429
+ <option value="paid-first" data-i18n="prefPaidFirst"></option>
430
+ <option value="paid-fallback" data-i18n="prefPaidFallback"></option>
431
+ </select>
432
+ <span class="opt-hint" id="optPreferenceState"></span>
433
+ </div>
434
+ <div class="opt-desc" data-i18n="optPreferenceDesc"></div>
406
435
  <div class="opt-note" id="optSaveNote"></div>
407
436
  </div>
408
437
  <div id="setup-grid" class="setup-grid" data-i18n="loading"></div>
@@ -425,11 +454,21 @@ const HTML = `<!DOCTYPE html>
425
454
  ru: {
426
455
  tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
427
456
  tabContext: 'Контекст', tabSetup: 'Настройки (ключи)',
457
+ tabDiag: 'Диагностика',
458
+ diagErrors: 'Ошибки сегодня (по кодам и провайдерам)', diagProviders: 'Провайдеры и статус',
459
+ diagSuccessRate: 'Успешность', diagCategory: 'Класс', diagHits: 'Ошибок',
460
+ diagInvalid: 'Невалидные (400/422) — не лимит, чинится в коде', diagLimit: 'Лимит/429 — выгорание дневных лимитов',
461
+ diagTimeout: 'Таймаут/сетевые', diagAuth: '401/402/403 — ключ/баланс', diagFix: 'Фикс',
462
+ diagFixSanitize: 'санитизация reasoning_effort', diagFixAuth: 'исключение из роутинга',
463
+ diagFixLimit: 'предохранитель выгорания', diagFixTimeout: 'дольше холдить', diagNone: 'нет',
428
464
  optBlock: 'Опции оптимизации', optCompress: 'Сжатие промптов', optVetting: 'Самопроверка', optRouting: 'Стратегия роутинга',
429
465
  optWeighted: 'weighted (умный)', optRoundrobin: 'weighted-roundrobin (по кругу)', optLeast: 'weighted-least (меньше нагруженные)',
430
466
  optCompressDesc: 'Убирает вежливость и «мусорные» слова («пожалуйста», «привет», «наверное») из последнего сообщения — меньше токенов, быстрее и дешевле. Не трогает код.',
431
467
  optVettingDesc: 'Сложные ответы (код/рассуждения) проверяет второй моделью и помечает ошибки. Тратит один лишний запрос на проверку.',
432
468
  optRoutingDesc: 'Как Freegate выбирает провайдера: умный взвешенный, по кругу (равномерно), или наименее нагруженные.', optSaved: 'Сохранено! Перезапусти Freegate для применения.',
469
+ optPreference: 'Предпочтение free↔paid',
470
+ prefFreeFirst: 'free-first (бесплатные в приоритете)', prefPaidFirst: 'paid-first (свои платные в приоритете)', prefPaidFallback: 'paid-fallback (платные — как запас)',
471
+ optPreferenceDesc: 'Как использовать твои платные ключи рядом с бесплатной базой: бесплатные всегда, свои платные в первую очередь, или платные только когда бесплатные упали. У платного провайдера в каталоге стоит флаг paid или free=false.',
433
472
  crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate', topTitle: 'Панель управления',
434
473
  kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
435
474
  kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
@@ -451,6 +490,7 @@ const HTML = `<!DOCTYPE html>
451
490
  thRecentTime: 'Время', thRecentModel: 'Модель', thRecentProvider: 'Провайдер', thRecentStatus: 'Статус', thRecentLatency: 'Задержка',
452
491
  allCategories: 'Все категории', allStatuses: 'Все статусы', allSources: 'Все источники',
453
492
  enable: 'Вкл', disable: 'Выкл', testNow: 'Тест', testing: 'Проверка...',
493
+ enabledShort: 'Вкл', disabledShort: 'Выкл',
454
494
  recBlock: 'Что включить', recActiveFree: 'Рабочие free — включи', recPaid: 'Платные/нестабильные', recLocal: 'Локальные (слабый Mac)',
455
495
  modelManagerInfo: 'Автопоиск', every: 'каждые', off: 'выключен', cycleRunning: 'цикл выполняется...',
456
496
  overviewLineOk: 'Всё работает штатно', overviewDown: 'Упали', overviewLimit: 'Лимит исчерпан',
@@ -459,6 +499,7 @@ const HTML = `<!DOCTYPE html>
459
499
  validateAll: 'Проверить все ключи', saveAllKeys: 'Сохранить все ключи',
460
500
  keySet: 'ключ установлен', keyMissing: 'не задан', checkKey: 'Проверить', valid: 'Ключ валиден', invalid: 'Неверный ключ',
461
501
  saving: 'Сохранение...', savedMsg: 'Сохранено! Перезапусти Freegate для применения.', saveErr: 'Ошибка сохранения',
502
+ saveKey: 'Сохранить ключ', savedKey: 'Ключ сохранён. Перезапусти Freegate.',
462
503
  ctxStatus: 'Статус', ctxReq: 'Запросов (24ч)', ctxRate: 'Rate (1/ч)', ctxAvgRatio: 'Avg ratio / окно',
463
504
  ctxMaxRatio: 'Max ratio', ctxNear: 'Впритык к окну', ctxOver: 'Поверх окна', ctxCompact: 'Компакций',
464
505
  ctxUpgrade: 'Апгрейдов (окно)', ctxCacheRate: 'Кэш-hit %', ctxSys: 'System-доля', ctxNarrow: 'Узкие',
@@ -468,11 +509,21 @@ const HTML = `<!DOCTYPE html>
468
509
  en: {
469
510
  tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
470
511
  tabContext: 'Context', tabSetup: 'Settings (keys)',
512
+ tabDiag: 'Diagnostics',
513
+ diagErrors: 'Today errors (by code & provider)', diagProviders: 'Providers and status',
514
+ diagSuccessRate: 'Success rate', diagCategory: 'Class', diagHits: 'Errors',
515
+ diagInvalid: 'Invalid (400/422) — not a limit, fixable in code', diagLimit: 'Rate/429 — daily limit burnout',
516
+ diagTimeout: 'Timeout/network', diagAuth: '401/402/403 — key/balance', diagFix: 'Fix',
517
+ diagFixSanitize: 'reasoning_effort sanitization', diagFixAuth: 'exclude from routing',
518
+ diagFixLimit: 'burnout guard', diagFixTimeout: 'backoff longer', diagNone: 'none',
471
519
  optBlock: 'Optimization options', optCompress: 'Prompt compression', optVetting: 'Self-check', optRouting: 'Routing strategy',
472
520
  optWeighted: 'weighted (smart)', optRoundrobin: 'weighted-roundrobin (round-robin)', optLeast: 'weighted-least (least-used)',
473
521
  optCompressDesc: 'Strips politeness and filler words ("please", "hello", "probably") from the last message — fewer tokens, faster and cheaper. Never touches code.',
474
522
  optVettingDesc: 'Complex answers (code/reasoning) get vetted by a second model and flagged on error. Costs one extra request per check.',
475
523
  optRoutingDesc: 'How Freegate picks a provider: smart weighted, round-robin (even), or least-loaded.', optSaved: 'Saved! Restart Freegate to apply.',
524
+ optPreference: 'Free↔paid preference',
525
+ prefFreeFirst: 'free-first (free in priority)', prefPaidFirst: 'paid-first (your paid keys in priority)', prefPaidFallback: 'paid-fallback (paid as a fallback)',
526
+ optPreferenceDesc: 'How to use your paid keys alongside the free base: free always, your paid first, or paid only when free fail. Mark a paid provider with the paid flag (free=false) in the catalog.',
476
527
  crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management', topTitle: 'Control Panel',
477
528
  kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
478
529
  kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
@@ -494,6 +545,7 @@ const HTML = `<!DOCTYPE html>
494
545
  thRecentTime: 'Time', thRecentModel: 'Model', thRecentProvider: 'Provider', thRecentStatus: 'Status', thRecentLatency: 'Latency',
495
546
  allCategories: 'All categories', allStatuses: 'All statuses', allSources: 'All sources',
496
547
  enable: 'On', disable: 'Off', testNow: 'Test', testing: 'Testing...',
548
+ enabledShort: 'On', disabledShort: 'Off',
497
549
  recBlock: 'What to enable', recActiveFree: 'Working free — enable', recPaid: 'Paid/unstable', recLocal: 'Local (weak Mac)',
498
550
  modelManagerInfo: 'Auto-discovery', every: 'every', off: 'off', cycleRunning: 'cycle running...',
499
551
  overviewLineOk: 'All systems operational', overviewDown: 'Down', overviewLimit: 'Limit exhausted',
@@ -502,6 +554,7 @@ const HTML = `<!DOCTYPE html>
502
554
  validateAll: 'Validate all keys', saveAllKeys: 'Save all keys',
503
555
  keySet: 'key set', keyMissing: 'not set', checkKey: 'Validate', valid: 'Key valid', invalid: 'Invalid key',
504
556
  saving: 'Saving...', savedMsg: 'Saved! Restart Freegate to apply.', saveErr: 'Save error',
557
+ saveKey: 'Save key', savedKey: 'Key saved. Restart Freegate.',
505
558
  ctxStatus: 'Status', ctxReq: 'Requests (24h)', ctxRate: 'Rate (1/h)', ctxAvgRatio: 'Avg ratio / window',
506
559
  ctxMaxRatio: 'Max ratio', ctxNear: 'Near window', ctxOver: 'Over window', ctxCompact: 'Compactions',
507
560
  ctxUpgrade: 'Upgrades (window)', ctxCacheRate: 'Cache-hit %', ctxSys: 'System share', ctxNarrow: 'Narrow',
@@ -551,7 +604,7 @@ const HTML = `<!DOCTYPE html>
551
604
  var _modelSort = { key: 'score', dir: -1 };
552
605
 
553
606
  // ── табы ──
554
- var _tabByKey = { overview: 'tabOverview', providers: 'tabProviders', models: 'tabModels', context: 'tabContext', setup: 'tabSetup' };
607
+ var _tabByKey = { overview: 'tabOverview', providers: 'tabProviders', models: 'tabModels', context: 'tabContext', diag: 'tabDiag', setup: 'tabSetup' };
555
608
  function showTab(tab) {
556
609
  var key = _tabByKey[tab];
557
610
  document.querySelectorAll('#tabs .tab').forEach(function (t) {
@@ -601,6 +654,8 @@ const HTML = `<!DOCTYPE html>
601
654
  _ctxSummary = _statsData.context_summary || null;
602
655
  _savings = _statsData.savings || null;
603
656
  _hourly = _statsData.hourly || [];
657
+ var v = document.getElementById('fgVersion');
658
+ if (v) v.textContent = _statsData.version ? ('· v' + _statsData.version) : '';
604
659
  renderKpi();
605
660
  renderAlerts();
606
661
  renderOverviewCards();
@@ -609,6 +664,7 @@ const HTML = `<!DOCTYPE html>
609
664
  renderRecent(_recent);
610
665
  renderProvidersTable();
611
666
  renderContext(_ctxSummary);
667
+ renderDiag();
612
668
  }).catch(function (e) { console.error('Ошибка обновления:', e); });
613
669
  }
614
670
 
@@ -941,8 +997,9 @@ const HTML = `<!DOCTYPE html>
941
997
  '<td><span class="badge ' + esc(m.status) + '">' + esc(m.status) + '</span></td>' +
942
998
  '<td>' + fmtDate(m.lastCheckedAt) + '</td>' +
943
999
  '<td class="model-actions">' +
944
- '<button class="mini-btn ' + (on ? '' : 'off') + '" onclick="toggleModel(\\'' + esc(m.key) + '\\')">' + (on ? '' : '') + '</button>' +
945
- '<button class="mini-btn" onclick="testModelLive(\\'' + esc(m.key) + '\\')" title="' + t('testNow') + '">' + t('testNow') + '</button>' +
1000
+ '<span class="mini-btn ' + (on ? 'on' : 'off') + '" onclick="toggleModel(\\'' + esc(m.key) + '\\')" title="' + (on ? t('disable') : t('enable')) + '">' + (on ? t('enabledShort') : t('disabledShort')) + '</span>' +
1001
+ '<span class="mini-btn" onclick="testModelLive(\\'' + esc(m.key) + '\\', this)">' + t('testNow') + '</span>' +
1002
+ '<span id="testres-' + esc(m.key) + '" class="test-res"></span>' +
946
1003
  '</td></tr>';
947
1004
  });
948
1005
  document.getElementById('modelTable').innerHTML = html;
@@ -961,14 +1018,17 @@ const HTML = `<!DOCTYPE html>
961
1018
  .then(function (d) { fetchModels(); renderKpi(); })
962
1019
  .catch(function (e) { alert(e.message); });
963
1020
  }
964
- function testModelLive(key) {
1021
+ function testModelLive(key, el) {
1022
+ var res = document.getElementById('testres-' + key);
1023
+ if (res) { res.className = 'test-res testing'; res.textContent = t('testing'); }
965
1024
  fetch(_api('/v1/models/' + encodeURIComponent(key) + '/test'), { method: 'POST' })
966
1025
  .then(function (r) { return r.json(); })
967
1026
  .then(function (d) {
968
- alert((d.ok ? 'OK' : 'FAIL (' + d.status + ')') + ' · ' + d.latencyMs + 'ms' + (d.error ? ' · ' + d.error : ''));
1027
+ var ok = !!d.ok;
1028
+ if (res) { res.className = 'test-res ' + (ok ? 'ok' : 'fail'); res.textContent = ok ? (d.latencyMs + 'ms ✓') : (d.status || 'FAIL'); }
969
1029
  fetchModels();
970
1030
  })
971
- .catch(function (e) { alert(e.message); });
1031
+ .catch(function (e) { if (res) { res.className = 'test-res fail'; res.textContent = 'ERR'; } });
972
1032
  }
973
1033
  function renderModelRecommend() {
974
1034
  var el = document.getElementById('modelRecommend');
@@ -1027,6 +1087,39 @@ const HTML = `<!DOCTYPE html>
1027
1087
  document.getElementById('taskBars').innerHTML = bars || '<div class="stat"><span class="stat-label">' + t('ctxNoTask') + '</span><span class="stat-value">—</span></div>';
1028
1088
  }
1029
1089
 
1090
+ // ── Диагностика: успешность по кодам, классы ошибок, статусы провайдеров ──
1091
+ function renderDiag() {
1092
+ var s = _statsData || {};
1093
+ var today = s.today || {};
1094
+ var errors = s.errors || {};
1095
+ var health = s.health || {};
1096
+ var upCnt = 0, errCnt = 0, ratelimitedCnt = 0;
1097
+ for (var ek in health) {
1098
+ var v = health[ek];
1099
+ if (v.status === 'up') upCnt++;
1100
+ else if (v.status === 'ratelimited') ratelimitedCnt++;
1101
+ else if (v.status === 'error') errCnt++;
1102
+ }
1103
+ var total = today.requests || 0;
1104
+ var successRate = total > 0 ? Math.round((today.success / total) * 100) : 100;
1105
+ document.getElementById('diagSummary').innerHTML =
1106
+ '<div class="card"><h2>' + t('diagSuccessRate') + '</h2><div class="stat"><span class="stat-value" style="font-size:28px;color:' + limitColor(successRate) + '">' + successRate + '%</span>' +
1107
+ '<span class="stat-label">' + today.success + ' / ' + total + '</span></div></div>' +
1108
+ '<div class="card"><h2>Up</h2><div class="stat"><span class="stat-value">' + upCnt + '</span><span class="stat-label">status up</span></div></div>' +
1109
+ '<div class="card"><h2>Limit</h2><div class="stat"><span class="stat-value" style="color:var(--warn)">' + ratelimitedCnt + '</span><span class="stat-label">429 (выгорели лимиты)</span></div></div>' +
1110
+ '<div class="card"><h2>Error</h2><div class="stat"><span class="stat-value" style="color:var(--err)">' + errCnt + '</span><span class="stat-label">' + t('diagAuth') + '</span></div></div>';
1111
+ var rows = Object.keys(errors).map(function (k) {
1112
+ var st = health[k] || {};
1113
+ return '<tr><td class="mono">' + esc(k) + '</td><td>' + errors[k] + '</td><td>' + esc(st.status || '?') + '</td><td>' + esc(st.reason || st.latency_ms || '') + '</td></tr>';
1114
+ }).sort(function (a, b) {
1115
+ var ma = a.match(/<td>(\\d+)<\\/td>/), mb = b.match(/<td>(\\d+)<\\/td>/);
1116
+ return (mb ? parseInt(mb[1], 10) : 0) - (ma ? parseInt(ma[1], 10) : 0);
1117
+ });
1118
+ document.getElementById('diagErrorsTable').innerHTML = rows.length
1119
+ ? '<tr><th>' + t('thKey') + '</th><th>count</th><th>status</th><th>reason</th></tr>' + rows.join('')
1120
+ : '<div class="stat">' + t('diagNone') + '</div>';
1121
+ }
1122
+
1030
1123
  // ── Setup (API keys) ──
1031
1124
  var _setupData = null;
1032
1125
  async function loadSetup() {
@@ -1046,6 +1139,7 @@ const HTML = `<!DOCTYPE html>
1046
1139
  document.getElementById('optCompress').checked = !!c.compress.enabled;
1047
1140
  document.getElementById('optVetting').checked = !!c.vetting.enabled;
1048
1141
  document.getElementById('optRouting').value = c.routing.strategy || 'weighted';
1142
+ document.getElementById('optPreference').value = c.routing.preference || 'free-first';
1049
1143
  var note = document.getElementById('optSaveNote');
1050
1144
  if (note) note.textContent = '';
1051
1145
  } catch (e) { console.error('Config load error:', e); }
@@ -1054,7 +1148,7 @@ const HTML = `<!DOCTYPE html>
1054
1148
  var body = {
1055
1149
  compress: { enabled: document.getElementById('optCompress').checked },
1056
1150
  vetting: { enabled: document.getElementById('optVetting').checked },
1057
- routing: { strategy: document.getElementById('optRouting').value },
1151
+ routing: { strategy: document.getElementById('optRouting').value, preference: document.getElementById('optPreference').value },
1058
1152
  };
1059
1153
  fetch(_api('/v1/config'), {
1060
1154
  method: 'POST',
@@ -1081,22 +1175,27 @@ const HTML = `<!DOCTYPE html>
1081
1175
  var steps = (isEn && info.steps_en) || info.steps;
1082
1176
  var maskedVal = (data.masked && data.masked[envVar]) || '';
1083
1177
  var stepsHtml = (steps || []).map(function (s) { return '<li>' + s + '</li>'; }).join('');
1178
+ var stClass = maskedVal ? 'status-ok' : 'status-wait';
1084
1179
  html += '<div class="key-card" id="card-' + envVar + '">';
1085
- html += '<h3>' + esc(name) + '</h3>';
1180
+ html += '<div class="key-head"><h3>' + esc(name) + '</h3><span id="badge-' + envVar + '" class="badge ' + (maskedVal ? 'active' : 'disabled') + '">' + (maskedVal ? '✓' : '—') + '</span></div>';
1086
1181
  html += '<div class="desc">' + esc(desc) + '</div>';
1087
1182
  html += '<ol>' + stepsHtml + '</ol>';
1088
1183
  html += '<div class="key-input-row">';
1089
1184
  html += '<input class="key-input" id="input-' + envVar + '" type="password" placeholder="' + (maskedVal ? t('keySet') + ' (' + maskedVal + ')' : t('checkKey') + '...') + '" autocomplete="off">';
1185
+ html += '</div>';
1186
+ html += '<div class="key-actions">';
1090
1187
  html += '<button class="btn btn-validate" data-validate="' + envVar + '">' + t('checkKey') + '</button>';
1188
+ html += '<button class="btn btn-save" data-save="' + envVar + '">' + t('saveKey') + '</button>';
1091
1189
  html += '</div>';
1092
- html += '<div id="status-' + envVar + '" class="status-wait" style="margin-top: 6px;">' + (maskedVal ? t('keySet') + ' (' + maskedVal + ')' : t('keyMissing')) + '</div>';
1093
- html += '<a href="' + info.url + '" target="_blank" style="font-size: 11px;">' + esc(info.url) + '</a>';
1190
+ html += '<div id="status-' + envVar + '" class="' + stClass + '" style="margin-top: 6px;">' + (maskedVal ? t('keySet') + ' (' + maskedVal + ')' : t('keyMissing')) + '</div>';
1191
+ html += '<a href="' + info.url + '" target="_blank" class="key-link" style="font-size: 11px;">' + esc(info.url) + ' ↗</a>';
1094
1192
  html += '</div>';
1095
1193
  }
1096
1194
  grid.innerHTML = html;
1097
1195
  }
1098
1196
  document.addEventListener('click', function (e) {
1099
1197
  if (e.target.dataset && e.target.dataset.validate) validateKey(e.target.dataset.validate);
1198
+ if (e.target.dataset && e.target.dataset.save) saveKey(e.target.dataset.save);
1100
1199
  });
1101
1200
  async function validateKey(envVar) {
1102
1201
  var input = document.getElementById('input-' + envVar);
@@ -1117,6 +1216,28 @@ const HTML = `<!DOCTYPE html>
1117
1216
  if (input && input.value.trim()) await validateKey(envVar);
1118
1217
  }
1119
1218
  }
1219
+ async function saveKey(envVar) {
1220
+ var input = document.getElementById('input-' + envVar);
1221
+ var status = document.getElementById('status-' + envVar);
1222
+ if (!input) return;
1223
+ var key = input.value.trim();
1224
+ status.className = 'status-wait'; status.textContent = t('saving');
1225
+ try {
1226
+ var res = await fetch(_api('/v1/setup/keys'), {
1227
+ method: 'POST',
1228
+ headers: { 'Content-Type': 'application/json' },
1229
+ body: JSON.stringify(((_k) => { var o = {}; o[_k] = key; return o; })(envVar))
1230
+ });
1231
+ var result = await res.json();
1232
+ if (result.ok) {
1233
+ status.className = 'status-ok'; status.textContent = t('savedKey');
1234
+ var badge = document.getElementById('badge-' + envVar);
1235
+ if (badge) { badge.className = 'badge active'; badge.textContent = '✓'; }
1236
+ } else {
1237
+ status.className = 'status-err'; status.textContent = (result.error || t('saveErr'));
1238
+ }
1239
+ } catch (e) { status.className = 'status-err'; status.textContent = e.message; }
1240
+ }
1120
1241
  async function saveAllKeys() {
1121
1242
  var status = document.getElementById('save-status');
1122
1243
  status.className = 'status-wait'; status.textContent = t('saving');
package/lib/diag.js ADDED
@@ -0,0 +1,81 @@
1
+ // Диагностика работы Freegate: успешность, классы ошибок, смена провайдеров.
2
+ // Собирает отчёт из живых данных прокси (/v1/stats + /v1/recent). Читает только
3
+ // публичные эндпоинты — можно гонять из любого окружения с доступом к порту.
4
+ function classifyErr(statusCode, reason, providerSide) {
5
+ if (statusCode === 429) return 'limit';
6
+ if (statusCode === 401 || statusCode === 402 || statusCode === 403) return 'auth';
7
+ if (statusCode >= 400 && statusCode < 500) return 'invalid'; // 400/422 и т.п. — формат запроса
8
+ if (statusCode === 0) return 'timeout'; // соединение/таймаут
9
+ if (providerSide) return 'upstream'; // провайдер временно недоступен (5xx/404 upstream)
10
+ // health-статусы без атрибутов кода: reason подсказывает класс.
11
+ if (/много ошибок|лимит|429/i.test(reason || '')) return 'limit';
12
+ if (/не отвечает|недоступен|флап|timeout/i.test(reason || '')) return 'timeout';
13
+ return 'invalid';
14
+ }
15
+
16
+ function buildReport(stats, recent) {
17
+ const today = stats.today || {};
18
+ const errors = stats.errors || {};
19
+ const health = stats.health || {};
20
+ const ctx = stats.context_summary || {};
21
+ const total = today.requests || 0;
22
+ const success = today.success || 0;
23
+
24
+ const classes = { limit: 0, auth: 0, invalid: 0, timeout: 0, upstream: 0, other: 0 };
25
+ const byClass = {};
26
+ for (const [key, cnt] of Object.entries(errors)) {
27
+ const h = health[key] || {};
28
+ const cls = classifyErr(h.statusCode, h.reason, h.providerSide);
29
+ classes[cls] += cnt;
30
+ byClass[key] = { errors: cnt, status: h.status || '?', reason: h.reason || '' };
31
+ }
32
+
33
+ const up = Object.values(health).filter((h) => h.status === 'up').length;
34
+ const ratelimited = Object.values(health).filter((h) => h.status === 'ratelimited').length;
35
+ const down = Object.values(health).filter((h) => h.status === 'error').length;
36
+
37
+ // Недавние запросы — смена провайдеров / кэш.
38
+ let recentOk = 0, recentErr = 0, cached = 0;
39
+ const recentRows = Array.isArray(recent) ? recent : [];
40
+ for (const r of recentRows) {
41
+ if (r.cached) cached++;
42
+ if (r.status === 200 || r.status === undefined) recentOk++;
43
+ else recentErr++;
44
+ }
45
+
46
+ const switches = new Set();
47
+ for (const r of recentRows) if (r.provider && r.provider !== 'cache' && r.provider !== 'semcache') switches.add(r.provider);
48
+
49
+ return {
50
+ version: stats.version,
51
+ uptime: stats.uptime_seconds,
52
+ today: { requests: total, success, failed: today.failed || 0, successRate: total > 0 ? Math.round((success / total) * 100) : null },
53
+ classes,
54
+ errors: byClass,
55
+ providers: { total: Object.keys(health).length, up, ratelimited, down },
56
+ context: { status: ctx.status, compactCount: ctx.compactCount || 0, overWindow: ctx.overWindow || 0, upgradedCount: ctx.upgradedCount || 0, cacheHitRate: ctx.cacheHitRate || 0, taskTotal: ctx.taskTotal || 0 },
57
+ recent: { total: recentRows.length, ok: recentOk, err: recentErr, cached, providersActive: switches.size },
58
+ provider_usage: stats.provider_usage,
59
+ bandit: stats.bandit,
60
+ last_selection: stats.last_selection,
61
+ };
62
+ }
63
+
64
+ const LABELS = {
65
+ limit: 'лимит/429 (выгорание дневных лимитов)',
66
+ auth: '401/402/403 (неверный ключ или нет баланса)',
67
+ invalid: '400/422 (несовместимый формат запроса)',
68
+ timeout: 'таймаут/сетевое (провайдер не ответил)',
69
+ upstream: 'провайдер временно недоступен (5xx/upstream 404)',
70
+ other: 'прочее',
71
+ };
72
+ const FIXES = {
73
+ limit: 'предохранитель выгорания / расширить пул',
74
+ auth: 'проверить ключ/баланс в .env',
75
+ invalid: 'санитизация unsupported-полей (reasoning_effort и т.п.)',
76
+ timeout: 'увеличить backoff / дольше холдить флап-провайдеров',
77
+ upstream: 'обрабатывается автоматически (circuit breaker)',
78
+ other: 'смотреть логи',
79
+ };
80
+
81
+ module.exports = { buildReport, classifyErr, LABELS, FIXES };
package/lib/providers.js CHANGED
@@ -165,13 +165,28 @@ const MODEL_MAP = {
165
165
  'deepseek-v4-flash-vision-exp': 'deepseek-vision',
166
166
  };
167
167
 
168
+ // Убирает поля, которые конкретный провайдер не принимает (иначе 400/422).
169
+ // Например cohere-совместимые API лишают `reasoning_effort` → 400. Поля,
170
+ // добавленные целенаправленно (tools/response_format), не трогаем — они не
171
+ // сюда. `provider.unsupportedFields` задаётся в каталоге providers.json.
172
+ function sanitizeBody(provider, body) {
173
+ const unsupported = provider.unsupportedFields || [];
174
+ if (unsupported.length === 0) return body;
175
+ const out = { ...body };
176
+ for (const f of unsupported) {
177
+ if (Object.prototype.hasOwnProperty.call(out, f)) delete out[f];
178
+ }
179
+ return out;
180
+ }
181
+
168
182
  function callProvider(provider, body, timeout = 30000, retries = 1) {
169
183
  const isStreaming = body.stream === true;
184
+ const cleanBody = sanitizeBody(provider, body);
170
185
 
171
186
  return new Promise((resolve, reject) => {
172
187
  const attempt = (remaining) => {
173
188
  const startTime = Date.now();
174
- const postData = JSON.stringify(body);
189
+ const postData = JSON.stringify(cleanBody);
175
190
  const url = new URL(provider.endpoint);
176
191
 
177
192
  const options = {
@@ -275,4 +290,4 @@ function callProvider(provider, body, timeout = 30000, retries = 1) {
275
290
  });
276
291
  }
277
292
 
278
- module.exports = { PROVIDERS, MODEL_MAP, callProvider, reloadProviders };
293
+ module.exports = { PROVIDERS, MODEL_MAP, callProvider, reloadProviders, sanitizeBody };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "freegate",
3
- "version": "0.6.21",
4
- "description": "Free multi-provider LLM gateway with automatic failover. One OpenAI-compatible endpoint routes to 25 free models (Groq, Mistral, Gemini, NIM, OpenRouter, ZAI, Cerebras, DeepSeek). Never pay for LLMs.",
3
+ "version": "0.6.23",
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": {
7
7
  "freegate": "./bin/freegate.js"
package/providers.json CHANGED
@@ -567,7 +567,11 @@
567
567
  "envVar": "PROVIDER_COHERE_APIKEY",
568
568
  "free": true,
569
569
  "category": "reasoning",
570
- "context_window": 131072
570
+ "context_window": 131072,
571
+ "unsupportedFields": [
572
+ "reasoning_effort",
573
+ "thinking"
574
+ ]
571
575
  },
572
576
  "cohere-command-r-plus": {
573
577
  "endpoint": "https://api.cohere.com/compatibility/v1/chat/completions",
@@ -578,7 +582,11 @@
578
582
  "envVar": "PROVIDER_COHERE_APIKEY",
579
583
  "free": true,
580
584
  "category": "general",
581
- "context_window": 131072
585
+ "context_window": 131072,
586
+ "unsupportedFields": [
587
+ "reasoning_effort",
588
+ "thinking"
589
+ ]
582
590
  },
583
591
  "llm7-gpt4o-mini": {
584
592
  "endpoint": "https://api.llm7.io/v1/chat/completions",
@@ -638,7 +646,7 @@
638
646
  "gemini-gemma-4-26b-a4b-it": {
639
647
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
640
648
  "model": "gemma-4-26b-a4b-it",
641
- "priority": 1,
649
+ "priority": 5,
642
650
  "dailyLimit": 1500,
643
651
  "keyHint": "gemini → Keys (автодобавлено, general)",
644
652
  "envVar": "PROVIDER_GEMINI_APIKEY",
@@ -648,7 +656,7 @@
648
656
  "gemini-gemma-4-31b-it": {
649
657
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
650
658
  "model": "gemma-4-31b-it",
651
- "priority": 5,
659
+ "priority": 1,
652
660
  "dailyLimit": 1500,
653
661
  "keyHint": "gemini → Keys (автодобавлено, general)",
654
662
  "envVar": "PROVIDER_GEMINI_APIKEY",
@@ -678,7 +686,7 @@
678
686
  "gemini-gemini-3-5-flash": {
679
687
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
680
688
  "model": "gemini-3.5-flash",
681
- "priority": 4,
689
+ "priority": 3,
682
690
  "dailyLimit": 1500,
683
691
  "keyHint": "gemini → Keys (автодобавлено, general)",
684
692
  "envVar": "PROVIDER_GEMINI_APIKEY",
@@ -688,7 +696,7 @@
688
696
  "gemini-gemini-3-5-flash-lite": {
689
697
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
690
698
  "model": "gemini-3.5-flash-lite",
691
- "priority": 3,
699
+ "priority": 4,
692
700
  "dailyLimit": 1500,
693
701
  "keyHint": "gemini → Keys (автодобавлено, general)",
694
702
  "envVar": "PROVIDER_GEMINI_APIKEY",
package/server.js CHANGED
@@ -70,6 +70,7 @@ function getArg(name, defaultVal) {
70
70
  const PORT = parseInt(process.env.PORT || getArg('port', config.port || '4000'));
71
71
  const AUTH_KEY = process.env.AUTH || getArg('auth', config.auth || '');
72
72
  const RATE_LIMIT = config.rateLimit || { maxRequests: 100, windowMs: 60000 };
73
+ const VERSION = (() => { try { return require('./package.json').version; } catch { return 'dev'; } })();
73
74
 
74
75
  // --- Долговременная память (vector memory) ---
75
76
  // По умолчанию включена: факты берутся побочно от компакции (без лишних вызовов
@@ -113,6 +114,12 @@ const VETTING_CONFIG = Object.assign(
113
114
  // По умолчанию 'weighted' — поведение без изменений.
114
115
  const { makeWeightModifier } = require('./lib/strategy');
115
116
  const ROUTING_STRATEGY = (config.routing && config.routing.strategy) || 'weighted';
117
+ // Предпочтение free↔paid. Пользователь задаёт свои платные ключи (.env) и
118
+ // выбирает, как их использовать рядом с бесплатной базой:
119
+ // free-first — free в приоритете, paid только как запас (default).
120
+ // paid-first — свои paid в приоритете, free как запас.
121
+ // paid-fallback— free по умолчанию, paid трогаем только если free упали.
122
+ const ROUTING_PREFERENCE = (config.routing && config.routing.preference) || 'free-first';
116
123
 
117
124
  // --- Сжатие промпта (Caveman-стиль) ---
118
125
  // Опционально убирает вежливость/заполнители из последнего user-сообщения,
@@ -153,6 +160,8 @@ const MODEL_MANAGER_CONFIG = Object.assign(
153
160
  {},
154
161
  (config.modelManager && typeof config.modelManager === 'object') ? config.modelManager : {}
155
162
  );
163
+ const autoUpdate = require('./lib/autoupdate');
164
+ const AUTO_UPDATE = (config.autoUpdate && typeof config.autoUpdate === 'object') ? config.autoUpdate : {};
156
165
  const modelManager = new ModelManager({
157
166
  dbPath: path.join(__dirname, 'models-db.json'),
158
167
  catalogPath: path.join(__dirname, 'providers.json'),
@@ -586,6 +595,17 @@ async function handleChatCompletion(req, res, body) {
586
595
  .filter(([_, p]) => p.enabled && !isCircuitOpen(p.key) && p.vision !== true &&
587
596
  (getHealth()[p.key]?.status === 'up' || getHealth()[p.key]?.status === 'ratelimited'));
588
597
 
598
+ // Анти-«замирание»: провайдер, накопивший много ошибок сегодня (status остаётся
599
+ // 'up' — это не 429/401, а флап/долгие таймауты), по-прежнему попадает в цепочку
600
+ // и тормозит ответ. Исключаем его из активного пула до конца дня. Если так пул
601
+ // пустеет (все «ошибочные») — вернём их как крайний резерв, чтобы не отдать 503.
602
+ const todayErrors = getStats().errors || {};
603
+ const ERROR_POOL_THRESHOLD = 15;
604
+ const lowErrorProviders = healthyProviders.filter(
605
+ ([k]) => (todayErrors[k] || 0) < ERROR_POOL_THRESHOLD
606
+ );
607
+ const healthy2 = lowErrorProviders.length > 0 ? lowErrorProviders : healthyProviders;
608
+
589
609
  // Window-aware routing: estimate the request size and only consider providers
590
610
  // whose context window can actually hold it. This stops large requests from
591
611
  // burning time falling through lfm (65k) / groq (131k) providers that reject
@@ -594,10 +614,10 @@ async function handleChatCompletion(req, res, body) {
594
614
  // requests keep the full fast pool.
595
615
  const requestTokens = estimateTokens(body.messages);
596
616
  const MIN_WINDOW = 50000; // below this we don't filter (typical requests)
597
- let windowPool = healthyProviders;
617
+ let windowPool = healthy2;
598
618
  let upgradeNoCapable = false; // апгрейд, но ни один здоровый провайдер не держит запрос
599
619
  if (requestTokens > MIN_WINDOW || windowUpgraded) {
600
- const capable = healthyProviders.filter(([_, p]) => {
620
+ const capable = healthy2.filter(([_, p]) => {
601
621
  const win = p.context_window || 0;
602
622
  // Unknown/0 window providers are kept (heuristic) — better to try than drop.
603
623
  return win === 0 || win >= requestTokens;
@@ -676,6 +696,16 @@ async function handleChatCompletion(req, res, body) {
676
696
  const usedToday = usedTodayFor(key);
677
697
  if (dailyLimit > 0 && usedToday >= dailyLimit) weight *= 0.03;
678
698
  else if (dailyLimit > 0 && usedToday >= dailyLimit * 0.9) weight *= 0.4;
699
+ // Предпочтение free↔paid (config.routing.preference). Провайдер «платный»,
700
+ // если только у него есть ключ и он не помечен как free — free-first
701
+ // оставляет как есть (weight 1), paid-first бустит paid, paid-fallback
702
+ // дебустит paid, пока живы свободные (иначе он как раз нужен).
703
+ const isPaid = provider.paid === true || provider.free === false;
704
+ if (isPaid && ROUTING_PREFERENCE === 'paid-first') weight *= 2.5;
705
+ else if (isPaid && ROUTING_PREFERENCE === 'paid-fallback') {
706
+ const freeUp = pool && pool.length > 0;
707
+ weight *= freeUp ? 0.1 : 1.0;
708
+ }
679
709
  // Стратегия роутинга: равномерность (round-robin / least-used) как
680
710
  // лёгкий модификатор к базовому weight — не ломает основной скоринг.
681
711
  weight *= strategyModifier(key);
@@ -1055,6 +1085,16 @@ async function handleChatCompletion(req, res, body) {
1055
1085
  if (statusCode === 429) {
1056
1086
  getHealth()[key].status = 'ratelimited';
1057
1087
  getHealth()[key].reason = 'лимит провайдера (429)';
1088
+ } else if (statusCode === 401 || statusCode === 402 || statusCode === 403) {
1089
+ // Auth/баланс — НЕ транзиентная ошибка: ключ неверен или нет кредитов.
1090
+ // Оставлять провайдера в пуле здесь бессмысленно — он будет получать
1091
+ // запросы и каждый раз фейлиться. Сразу убираем из активной выборки.
1092
+ // Периодический health-check/modelManager может вернуть его, когда
1093
+ // ключ/баланс поправят (recheckDisabledDays).
1094
+ getHealth()[key].status = 'error';
1095
+ getHealth()[key].reason = statusCode === 401 ? 'неверный ключ' : statusCode === 402 ? 'нет средств/баланса' : 'доступ запрещён';
1096
+ getHealth()[key].score = Math.max(0, (getHealth()[key].score || 50) - 25);
1097
+ logger.warn('Provider auth/balance error, excluding from routing', { key, statusCode });
1058
1098
  } else if (statusCode !== 404 && getHealth()[key].status === 'up') {
1059
1099
  // keep 'up' — it may just be a transient blip; health-check re-verifies
1060
1100
  } else {
@@ -1271,7 +1311,7 @@ const server = http.createServer(async (req, res) => {
1271
1311
  res.end(JSON.stringify({
1272
1312
  compress: { enabled: !!(userCfg.compress && userCfg.compress.enabled), minLen: userCfg.compress?.minLen || 60 },
1273
1313
  vetting: { enabled: !!(userCfg.vetting && userCfg.vetting.enabled), minAnswerLen: userCfg.vetting?.minAnswerLen || 120, complexityOnly: userCfg.vetting?.complexityOnly !== false },
1274
- routing: { strategy: userCfg.routing?.strategy || 'weighted' },
1314
+ routing: { strategy: userCfg.routing?.strategy || 'weighted', preference: userCfg.routing?.preference || 'free-first' },
1275
1315
  }));
1276
1316
  return;
1277
1317
  }
@@ -1283,7 +1323,7 @@ const server = http.createServer(async (req, res) => {
1283
1323
  const patch = JSON.parse(body || '{}');
1284
1324
  if (typeof patch.compress === 'object') userCfg.compress = Object.assign({ enabled: false, minLen: 60 }, userCfg.compress, patch.compress);
1285
1325
  if (typeof patch.vetting === 'object') userCfg.vetting = Object.assign({ enabled: false, minAnswerLen: 120, complexityOnly: true }, userCfg.vetting, patch.vetting);
1286
- if (typeof patch.routing === 'object') userCfg.routing = Object.assign({ strategy: 'weighted' }, userCfg.routing, patch.routing);
1326
+ if (typeof patch.routing === 'object') userCfg.routing = Object.assign({ strategy: 'weighted', preference: 'free-first' }, userCfg.routing, patch.routing);
1287
1327
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1288
1328
  res.writeHead(200, { 'Content-Type': 'application/json' });
1289
1329
  res.end(JSON.stringify({ ok: true }));
@@ -1435,6 +1475,7 @@ const server = http.createServer(async (req, res) => {
1435
1475
  }
1436
1476
  res.writeHead(200, { 'Content-Type': 'application/json' });
1437
1477
  res.end(JSON.stringify({
1478
+ version: VERSION,
1438
1479
  total_requests: s.totalRequests, successful_requests: s.successfulRequests, failed_requests: s.failedRequests,
1439
1480
  provider_usage: s.providerUsage, token_usage: s.tokenUsage, errors: s.errors, uptime_seconds: Math.floor((Date.now() - s.startTime) / 1000),
1440
1481
  today: (() => {
@@ -1710,6 +1751,11 @@ server.listen(PORT, process.env.HOST || '127.0.0.1', () => {
1710
1751
  modelManager.start();
1711
1752
  logger.info('ModelManager started', { intervalHours: modelManager.config.intervalHours });
1712
1753
  }
1754
+ // Автообновление на лету: git fetch + ff-pull + перезапуск, без действий юзера.
1755
+ if (AUTO_UPDATE.enabled) {
1756
+ autoUpdate.startAutoUpdate({ root: __dirname, log: (m) => logger.info(m) });
1757
+ logger.info('AutoUpdate enabled', { intervalMs: AUTO_UPDATE.intervalMs });
1758
+ }
1713
1759
  });
1714
1760
 
1715
1761
  const _shutdown = () => {