freegate 0.6.21 → 0.6.22
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 +26 -0
- package/README.ru.md +27 -0
- package/bin/freegate.js +37 -0
- package/config.example.json +6 -1
- package/lib/autoupdate.js +64 -0
- package/lib/dashboard.js +134 -13
- package/lib/diag.js +81 -0
- package/lib/providers.js +17 -2
- package/package.json +2 -2
- package/providers.json +14 -6
- package/server.js +37 -2
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 автозапуск при старте системы');
|
package/config.example.json
CHANGED
|
@@ -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/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.
|
|
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
|
|
287
|
-
<div class="role"
|
|
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
|
-
'<
|
|
945
|
-
'<
|
|
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
|
-
|
|
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) {
|
|
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="
|
|
1093
|
-
html += '<a href="' + info.url + '" target="_blank" style="font-size: 11px;">' + esc(info.url) + '
|
|
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(
|
|
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.
|
|
4
|
-
"description": "Free multi-provider LLM gateway with automatic failover. One OpenAI-compatible endpoint routes to
|
|
3
|
+
"version": "0.6.22",
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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'),
|
|
@@ -676,6 +685,16 @@ async function handleChatCompletion(req, res, body) {
|
|
|
676
685
|
const usedToday = usedTodayFor(key);
|
|
677
686
|
if (dailyLimit > 0 && usedToday >= dailyLimit) weight *= 0.03;
|
|
678
687
|
else if (dailyLimit > 0 && usedToday >= dailyLimit * 0.9) weight *= 0.4;
|
|
688
|
+
// Предпочтение free↔paid (config.routing.preference). Провайдер «платный»,
|
|
689
|
+
// если только у него есть ключ и он не помечен как free — free-first
|
|
690
|
+
// оставляет как есть (weight 1), paid-first бустит paid, paid-fallback
|
|
691
|
+
// дебустит paid, пока живы свободные (иначе он как раз нужен).
|
|
692
|
+
const isPaid = provider.paid === true || provider.free === false;
|
|
693
|
+
if (isPaid && ROUTING_PREFERENCE === 'paid-first') weight *= 2.5;
|
|
694
|
+
else if (isPaid && ROUTING_PREFERENCE === 'paid-fallback') {
|
|
695
|
+
const freeUp = pool && pool.length > 0;
|
|
696
|
+
weight *= freeUp ? 0.1 : 1.0;
|
|
697
|
+
}
|
|
679
698
|
// Стратегия роутинга: равномерность (round-robin / least-used) как
|
|
680
699
|
// лёгкий модификатор к базовому weight — не ломает основной скоринг.
|
|
681
700
|
weight *= strategyModifier(key);
|
|
@@ -1055,6 +1074,16 @@ async function handleChatCompletion(req, res, body) {
|
|
|
1055
1074
|
if (statusCode === 429) {
|
|
1056
1075
|
getHealth()[key].status = 'ratelimited';
|
|
1057
1076
|
getHealth()[key].reason = 'лимит провайдера (429)';
|
|
1077
|
+
} else if (statusCode === 401 || statusCode === 402 || statusCode === 403) {
|
|
1078
|
+
// Auth/баланс — НЕ транзиентная ошибка: ключ неверен или нет кредитов.
|
|
1079
|
+
// Оставлять провайдера в пуле здесь бессмысленно — он будет получать
|
|
1080
|
+
// запросы и каждый раз фейлиться. Сразу убираем из активной выборки.
|
|
1081
|
+
// Периодический health-check/modelManager может вернуть его, когда
|
|
1082
|
+
// ключ/баланс поправят (recheckDisabledDays).
|
|
1083
|
+
getHealth()[key].status = 'error';
|
|
1084
|
+
getHealth()[key].reason = statusCode === 401 ? 'неверный ключ' : statusCode === 402 ? 'нет средств/баланса' : 'доступ запрещён';
|
|
1085
|
+
getHealth()[key].score = Math.max(0, (getHealth()[key].score || 50) - 25);
|
|
1086
|
+
logger.warn('Provider auth/balance error, excluding from routing', { key, statusCode });
|
|
1058
1087
|
} else if (statusCode !== 404 && getHealth()[key].status === 'up') {
|
|
1059
1088
|
// keep 'up' — it may just be a transient blip; health-check re-verifies
|
|
1060
1089
|
} else {
|
|
@@ -1271,7 +1300,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1271
1300
|
res.end(JSON.stringify({
|
|
1272
1301
|
compress: { enabled: !!(userCfg.compress && userCfg.compress.enabled), minLen: userCfg.compress?.minLen || 60 },
|
|
1273
1302
|
vetting: { enabled: !!(userCfg.vetting && userCfg.vetting.enabled), minAnswerLen: userCfg.vetting?.minAnswerLen || 120, complexityOnly: userCfg.vetting?.complexityOnly !== false },
|
|
1274
|
-
routing: { strategy: userCfg.routing?.strategy || 'weighted' },
|
|
1303
|
+
routing: { strategy: userCfg.routing?.strategy || 'weighted', preference: userCfg.routing?.preference || 'free-first' },
|
|
1275
1304
|
}));
|
|
1276
1305
|
return;
|
|
1277
1306
|
}
|
|
@@ -1283,7 +1312,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1283
1312
|
const patch = JSON.parse(body || '{}');
|
|
1284
1313
|
if (typeof patch.compress === 'object') userCfg.compress = Object.assign({ enabled: false, minLen: 60 }, userCfg.compress, patch.compress);
|
|
1285
1314
|
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);
|
|
1315
|
+
if (typeof patch.routing === 'object') userCfg.routing = Object.assign({ strategy: 'weighted', preference: 'free-first' }, userCfg.routing, patch.routing);
|
|
1287
1316
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
|
|
1288
1317
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1289
1318
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -1435,6 +1464,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1435
1464
|
}
|
|
1436
1465
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1437
1466
|
res.end(JSON.stringify({
|
|
1467
|
+
version: VERSION,
|
|
1438
1468
|
total_requests: s.totalRequests, successful_requests: s.successfulRequests, failed_requests: s.failedRequests,
|
|
1439
1469
|
provider_usage: s.providerUsage, token_usage: s.tokenUsage, errors: s.errors, uptime_seconds: Math.floor((Date.now() - s.startTime) / 1000),
|
|
1440
1470
|
today: (() => {
|
|
@@ -1710,6 +1740,11 @@ server.listen(PORT, process.env.HOST || '127.0.0.1', () => {
|
|
|
1710
1740
|
modelManager.start();
|
|
1711
1741
|
logger.info('ModelManager started', { intervalHours: modelManager.config.intervalHours });
|
|
1712
1742
|
}
|
|
1743
|
+
// Автообновление на лету: git fetch + ff-pull + перезапуск, без действий юзера.
|
|
1744
|
+
if (AUTO_UPDATE.enabled) {
|
|
1745
|
+
autoUpdate.startAutoUpdate({ root: __dirname, log: (m) => logger.info(m) });
|
|
1746
|
+
logger.info('AutoUpdate enabled', { intervalMs: AUTO_UPDATE.intervalMs });
|
|
1747
|
+
}
|
|
1713
1748
|
});
|
|
1714
1749
|
|
|
1715
1750
|
const _shutdown = () => {
|