freegate 0.6.20 → 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 +207 -12
- package/lib/diag.js +81 -0
- package/lib/modelmanager.js +14 -0
- package/lib/providers.js +17 -2
- package/package.json +2 -2
- package/providers.json +81 -3
- package/server.js +86 -0
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; }
|
|
@@ -238,6 +248,19 @@ const HTML = `<!DOCTYPE html>
|
|
|
238
248
|
.status-wait { color: var(--muted); font-size: 12px; }
|
|
239
249
|
.hidden { display: none; }
|
|
240
250
|
|
|
251
|
+
/* ── Опции оптимизации ── */
|
|
252
|
+
.opt-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 2px; font-size: 13.5px; font-weight: 600; }
|
|
253
|
+
.opt-switch { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
|
254
|
+
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
|
|
255
|
+
.switch input { display: none; }
|
|
256
|
+
.slider { position: absolute; inset: 0; background: var(--panel2); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: all .15s ease; }
|
|
257
|
+
.slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; background: var(--muted); border-radius: 50%; transition: all .15s ease; }
|
|
258
|
+
.switch input:checked + .slider { background: rgba(124,92,255,.35); border-color: var(--accent); }
|
|
259
|
+
.switch input:checked + .slider::before { transform: translateX(18px); background: #fff; }
|
|
260
|
+
.opt-hint { font-size: 12px; color: var(--accent-dim); font-weight: 500; }
|
|
261
|
+
.opt-desc { font-size: 12px; color: var(--muted); line-height: 1.55; margin: 0 0 8px; }
|
|
262
|
+
.opt-note { font-size: 12px; color: var(--ok); margin-top: 8px; }
|
|
263
|
+
|
|
241
264
|
@media (max-width: 900px) {
|
|
242
265
|
#sidebar { width: 64px; padding: 20px 8px; }
|
|
243
266
|
.side-brand .name, .tab .label, .side-user .info { display: none; }
|
|
@@ -263,6 +286,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
263
286
|
<div class="tab" data-i18n="tabProviders" onclick="showTab('providers')"></div>
|
|
264
287
|
<div class="tab" data-i18n="tabModels" onclick="showTab('models')"></div>
|
|
265
288
|
<div class="tab" data-i18n="tabContext" onclick="showTab('context')"></div>
|
|
289
|
+
<div class="tab" data-i18n="tabDiag" onclick="showTab('diag')"></div>
|
|
266
290
|
<div class="tab" data-i18n="tabSetup" onclick="showTab('setup')"></div>
|
|
267
291
|
</nav>
|
|
268
292
|
|
|
@@ -270,8 +294,8 @@ const HTML = `<!DOCTYPE html>
|
|
|
270
294
|
<div class="side-user">
|
|
271
295
|
<div class="side-avatar">F</div>
|
|
272
296
|
<div class="info">
|
|
273
|
-
<div class="email">Freegate
|
|
274
|
-
<div class="role"
|
|
297
|
+
<div class="email">Freegate <span id="fgVersion" class="version"></span></div>
|
|
298
|
+
<div class="role" data-i18n="sideRole"></div>
|
|
275
299
|
</div>
|
|
276
300
|
</div>
|
|
277
301
|
</div>
|
|
@@ -368,6 +392,15 @@ const HTML = `<!DOCTYPE html>
|
|
|
368
392
|
</div>
|
|
369
393
|
</section>
|
|
370
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
|
+
|
|
371
404
|
<!-- ═══ НАСТРОЙКИ ═══ -->
|
|
372
405
|
<section id="panel-setup" class="panel">
|
|
373
406
|
<div style="margin-bottom: 16px;">
|
|
@@ -375,6 +408,32 @@ const HTML = `<!DOCTYPE html>
|
|
|
375
408
|
<button class="btn btn-save" data-i18n="saveAllKeys" onclick="saveAllKeys()" style="margin-left: 8px;"></button>
|
|
376
409
|
<span id="save-status" class="status-wait" style="margin-left: 12px;"></span>
|
|
377
410
|
</div>
|
|
411
|
+
<div class="card" style="margin-bottom:14px;">
|
|
412
|
+
<h2 data-i18n="optBlock"></h2>
|
|
413
|
+
<div class="opt-row"><span data-i18n="optCompress"></span><div class="opt-switch"><label class="switch"><input type="checkbox" id="optCompress"><span class="slider"></span></label><span class="opt-hint" id="optCompressState"></span></div></div>
|
|
414
|
+
<div class="opt-desc" data-i18n="optCompressDesc"></div>
|
|
415
|
+
<div class="opt-row"><span data-i18n="optVetting"></span><div class="opt-switch"><label class="switch"><input type="checkbox" id="optVetting"><span class="slider"></span></label><span class="opt-hint" id="optVettingState"></span></div></div>
|
|
416
|
+
<div class="opt-desc" data-i18n="optVettingDesc"></div>
|
|
417
|
+
<div class="opt-row"><span data-i18n="optRouting"></span>
|
|
418
|
+
<select id="optRouting" class="filter" onchange="saveOpts()">
|
|
419
|
+
<option value="weighted" data-i18n="optWeighted"></option>
|
|
420
|
+
<option value="weighted-roundrobin" data-i18n="optRoundrobin"></option>
|
|
421
|
+
<option value="weighted-least" data-i18n="optLeast"></option>
|
|
422
|
+
</select>
|
|
423
|
+
<span class="opt-hint" id="optRoutingState"></span>
|
|
424
|
+
</div>
|
|
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>
|
|
435
|
+
<div class="opt-note" id="optSaveNote"></div>
|
|
436
|
+
</div>
|
|
378
437
|
<div id="setup-grid" class="setup-grid" data-i18n="loading"></div>
|
|
379
438
|
</section>
|
|
380
439
|
</main>
|
|
@@ -395,6 +454,21 @@ const HTML = `<!DOCTYPE html>
|
|
|
395
454
|
ru: {
|
|
396
455
|
tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
|
|
397
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: 'нет',
|
|
464
|
+
optBlock: 'Опции оптимизации', optCompress: 'Сжатие промптов', optVetting: 'Самопроверка', optRouting: 'Стратегия роутинга',
|
|
465
|
+
optWeighted: 'weighted (умный)', optRoundrobin: 'weighted-roundrobin (по кругу)', optLeast: 'weighted-least (меньше нагруженные)',
|
|
466
|
+
optCompressDesc: 'Убирает вежливость и «мусорные» слова («пожалуйста», «привет», «наверное») из последнего сообщения — меньше токенов, быстрее и дешевле. Не трогает код.',
|
|
467
|
+
optVettingDesc: 'Сложные ответы (код/рассуждения) проверяет второй моделью и помечает ошибки. Тратит один лишний запрос на проверку.',
|
|
468
|
+
optRoutingDesc: 'Как Freegate выбирает провайдера: умный взвешенный, по кругу (равномерно), или наименее нагруженные.', optSaved: 'Сохранено! Перезапусти Freegate для применения.',
|
|
469
|
+
optPreference: 'Предпочтение free↔paid',
|
|
470
|
+
prefFreeFirst: 'free-first (бесплатные в приоритете)', prefPaidFirst: 'paid-first (свои платные в приоритете)', prefPaidFallback: 'paid-fallback (платные — как запас)',
|
|
471
|
+
optPreferenceDesc: 'Как использовать твои платные ключи рядом с бесплатной базой: бесплатные всегда, свои платные в первую очередь, или платные только когда бесплатные упали. У платного провайдера в каталоге стоит флаг paid или free=false.',
|
|
398
472
|
crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate', topTitle: 'Панель управления',
|
|
399
473
|
kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
|
|
400
474
|
kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
|
|
@@ -416,6 +490,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
416
490
|
thRecentTime: 'Время', thRecentModel: 'Модель', thRecentProvider: 'Провайдер', thRecentStatus: 'Статус', thRecentLatency: 'Задержка',
|
|
417
491
|
allCategories: 'Все категории', allStatuses: 'Все статусы', allSources: 'Все источники',
|
|
418
492
|
enable: 'Вкл', disable: 'Выкл', testNow: 'Тест', testing: 'Проверка...',
|
|
493
|
+
enabledShort: 'Вкл', disabledShort: 'Выкл',
|
|
419
494
|
recBlock: 'Что включить', recActiveFree: 'Рабочие free — включи', recPaid: 'Платные/нестабильные', recLocal: 'Локальные (слабый Mac)',
|
|
420
495
|
modelManagerInfo: 'Автопоиск', every: 'каждые', off: 'выключен', cycleRunning: 'цикл выполняется...',
|
|
421
496
|
overviewLineOk: 'Всё работает штатно', overviewDown: 'Упали', overviewLimit: 'Лимит исчерпан',
|
|
@@ -424,6 +499,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
424
499
|
validateAll: 'Проверить все ключи', saveAllKeys: 'Сохранить все ключи',
|
|
425
500
|
keySet: 'ключ установлен', keyMissing: 'не задан', checkKey: 'Проверить', valid: 'Ключ валиден', invalid: 'Неверный ключ',
|
|
426
501
|
saving: 'Сохранение...', savedMsg: 'Сохранено! Перезапусти Freegate для применения.', saveErr: 'Ошибка сохранения',
|
|
502
|
+
saveKey: 'Сохранить ключ', savedKey: 'Ключ сохранён. Перезапусти Freegate.',
|
|
427
503
|
ctxStatus: 'Статус', ctxReq: 'Запросов (24ч)', ctxRate: 'Rate (1/ч)', ctxAvgRatio: 'Avg ratio / окно',
|
|
428
504
|
ctxMaxRatio: 'Max ratio', ctxNear: 'Впритык к окну', ctxOver: 'Поверх окна', ctxCompact: 'Компакций',
|
|
429
505
|
ctxUpgrade: 'Апгрейдов (окно)', ctxCacheRate: 'Кэш-hit %', ctxSys: 'System-доля', ctxNarrow: 'Узкие',
|
|
@@ -433,6 +509,21 @@ const HTML = `<!DOCTYPE html>
|
|
|
433
509
|
en: {
|
|
434
510
|
tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
|
|
435
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',
|
|
519
|
+
optBlock: 'Optimization options', optCompress: 'Prompt compression', optVetting: 'Self-check', optRouting: 'Routing strategy',
|
|
520
|
+
optWeighted: 'weighted (smart)', optRoundrobin: 'weighted-roundrobin (round-robin)', optLeast: 'weighted-least (least-used)',
|
|
521
|
+
optCompressDesc: 'Strips politeness and filler words ("please", "hello", "probably") from the last message — fewer tokens, faster and cheaper. Never touches code.',
|
|
522
|
+
optVettingDesc: 'Complex answers (code/reasoning) get vetted by a second model and flagged on error. Costs one extra request per check.',
|
|
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.',
|
|
436
527
|
crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management', topTitle: 'Control Panel',
|
|
437
528
|
kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
|
|
438
529
|
kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
|
|
@@ -454,6 +545,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
454
545
|
thRecentTime: 'Time', thRecentModel: 'Model', thRecentProvider: 'Provider', thRecentStatus: 'Status', thRecentLatency: 'Latency',
|
|
455
546
|
allCategories: 'All categories', allStatuses: 'All statuses', allSources: 'All sources',
|
|
456
547
|
enable: 'On', disable: 'Off', testNow: 'Test', testing: 'Testing...',
|
|
548
|
+
enabledShort: 'On', disabledShort: 'Off',
|
|
457
549
|
recBlock: 'What to enable', recActiveFree: 'Working free — enable', recPaid: 'Paid/unstable', recLocal: 'Local (weak Mac)',
|
|
458
550
|
modelManagerInfo: 'Auto-discovery', every: 'every', off: 'off', cycleRunning: 'cycle running...',
|
|
459
551
|
overviewLineOk: 'All systems operational', overviewDown: 'Down', overviewLimit: 'Limit exhausted',
|
|
@@ -462,6 +554,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
462
554
|
validateAll: 'Validate all keys', saveAllKeys: 'Save all keys',
|
|
463
555
|
keySet: 'key set', keyMissing: 'not set', checkKey: 'Validate', valid: 'Key valid', invalid: 'Invalid key',
|
|
464
556
|
saving: 'Saving...', savedMsg: 'Saved! Restart Freegate to apply.', saveErr: 'Save error',
|
|
557
|
+
saveKey: 'Save key', savedKey: 'Key saved. Restart Freegate.',
|
|
465
558
|
ctxStatus: 'Status', ctxReq: 'Requests (24h)', ctxRate: 'Rate (1/h)', ctxAvgRatio: 'Avg ratio / window',
|
|
466
559
|
ctxMaxRatio: 'Max ratio', ctxNear: 'Near window', ctxOver: 'Over window', ctxCompact: 'Compactions',
|
|
467
560
|
ctxUpgrade: 'Upgrades (window)', ctxCacheRate: 'Cache-hit %', ctxSys: 'System share', ctxNarrow: 'Narrow',
|
|
@@ -511,7 +604,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
511
604
|
var _modelSort = { key: 'score', dir: -1 };
|
|
512
605
|
|
|
513
606
|
// ── табы ──
|
|
514
|
-
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' };
|
|
515
608
|
function showTab(tab) {
|
|
516
609
|
var key = _tabByKey[tab];
|
|
517
610
|
document.querySelectorAll('#tabs .tab').forEach(function (t) {
|
|
@@ -561,6 +654,8 @@ const HTML = `<!DOCTYPE html>
|
|
|
561
654
|
_ctxSummary = _statsData.context_summary || null;
|
|
562
655
|
_savings = _statsData.savings || null;
|
|
563
656
|
_hourly = _statsData.hourly || [];
|
|
657
|
+
var v = document.getElementById('fgVersion');
|
|
658
|
+
if (v) v.textContent = _statsData.version ? ('· v' + _statsData.version) : '';
|
|
564
659
|
renderKpi();
|
|
565
660
|
renderAlerts();
|
|
566
661
|
renderOverviewCards();
|
|
@@ -569,6 +664,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
569
664
|
renderRecent(_recent);
|
|
570
665
|
renderProvidersTable();
|
|
571
666
|
renderContext(_ctxSummary);
|
|
667
|
+
renderDiag();
|
|
572
668
|
}).catch(function (e) { console.error('Ошибка обновления:', e); });
|
|
573
669
|
}
|
|
574
670
|
|
|
@@ -901,8 +997,9 @@ const HTML = `<!DOCTYPE html>
|
|
|
901
997
|
'<td><span class="badge ' + esc(m.status) + '">' + esc(m.status) + '</span></td>' +
|
|
902
998
|
'<td>' + fmtDate(m.lastCheckedAt) + '</td>' +
|
|
903
999
|
'<td class="model-actions">' +
|
|
904
|
-
'<
|
|
905
|
-
'<
|
|
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>' +
|
|
906
1003
|
'</td></tr>';
|
|
907
1004
|
});
|
|
908
1005
|
document.getElementById('modelTable').innerHTML = html;
|
|
@@ -921,14 +1018,17 @@ const HTML = `<!DOCTYPE html>
|
|
|
921
1018
|
.then(function (d) { fetchModels(); renderKpi(); })
|
|
922
1019
|
.catch(function (e) { alert(e.message); });
|
|
923
1020
|
}
|
|
924
|
-
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'); }
|
|
925
1024
|
fetch(_api('/v1/models/' + encodeURIComponent(key) + '/test'), { method: 'POST' })
|
|
926
1025
|
.then(function (r) { return r.json(); })
|
|
927
1026
|
.then(function (d) {
|
|
928
|
-
|
|
1027
|
+
var ok = !!d.ok;
|
|
1028
|
+
if (res) { res.className = 'test-res ' + (ok ? 'ok' : 'fail'); res.textContent = ok ? (d.latencyMs + 'ms ✓') : (d.status || 'FAIL'); }
|
|
929
1029
|
fetchModels();
|
|
930
1030
|
})
|
|
931
|
-
.catch(function (e) {
|
|
1031
|
+
.catch(function (e) { if (res) { res.className = 'test-res fail'; res.textContent = 'ERR'; } });
|
|
932
1032
|
}
|
|
933
1033
|
function renderModelRecommend() {
|
|
934
1034
|
var el = document.getElementById('modelRecommend');
|
|
@@ -987,6 +1087,39 @@ const HTML = `<!DOCTYPE html>
|
|
|
987
1087
|
document.getElementById('taskBars').innerHTML = bars || '<div class="stat"><span class="stat-label">' + t('ctxNoTask') + '</span><span class="stat-value">—</span></div>';
|
|
988
1088
|
}
|
|
989
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
|
+
|
|
990
1123
|
// ── Setup (API keys) ──
|
|
991
1124
|
var _setupData = null;
|
|
992
1125
|
async function loadSetup() {
|
|
@@ -995,7 +1128,42 @@ const HTML = `<!DOCTYPE html>
|
|
|
995
1128
|
_setupData = await res.json();
|
|
996
1129
|
renderSetup(_setupData);
|
|
997
1130
|
} catch (e) { console.error('Setup load error:', e); }
|
|
1131
|
+
loadOpts();
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// ── Опции оптимизации (compress/vetting/routing) ──
|
|
1135
|
+
async function loadOpts() {
|
|
1136
|
+
try {
|
|
1137
|
+
var res = await fetch(_api('/v1/config'));
|
|
1138
|
+
var c = await res.json();
|
|
1139
|
+
document.getElementById('optCompress').checked = !!c.compress.enabled;
|
|
1140
|
+
document.getElementById('optVetting').checked = !!c.vetting.enabled;
|
|
1141
|
+
document.getElementById('optRouting').value = c.routing.strategy || 'weighted';
|
|
1142
|
+
document.getElementById('optPreference').value = c.routing.preference || 'free-first';
|
|
1143
|
+
var note = document.getElementById('optSaveNote');
|
|
1144
|
+
if (note) note.textContent = '';
|
|
1145
|
+
} catch (e) { console.error('Config load error:', e); }
|
|
998
1146
|
}
|
|
1147
|
+
function saveOpts() {
|
|
1148
|
+
var body = {
|
|
1149
|
+
compress: { enabled: document.getElementById('optCompress').checked },
|
|
1150
|
+
vetting: { enabled: document.getElementById('optVetting').checked },
|
|
1151
|
+
routing: { strategy: document.getElementById('optRouting').value, preference: document.getElementById('optPreference').value },
|
|
1152
|
+
};
|
|
1153
|
+
fetch(_api('/v1/config'), {
|
|
1154
|
+
method: 'POST',
|
|
1155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1156
|
+
body: JSON.stringify(body),
|
|
1157
|
+
}).then(function (r) { return r.json(); }).then(function (d) {
|
|
1158
|
+
var note = document.getElementById('optSaveNote');
|
|
1159
|
+
if (note) note.textContent = d.ok ? (t('optSaved')) : (d.error || t('saveErr'));
|
|
1160
|
+
}).catch(function (e) {
|
|
1161
|
+
var note = document.getElementById('optSaveNote');
|
|
1162
|
+
if (note) note.textContent = e.message;
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
document.getElementById('optCompress').addEventListener('change', saveOpts);
|
|
1166
|
+
document.getElementById('optVetting').addEventListener('change', saveOpts);
|
|
999
1167
|
function renderSetup(data) {
|
|
1000
1168
|
var grid = document.getElementById('setup-grid');
|
|
1001
1169
|
var html = '';
|
|
@@ -1007,22 +1175,27 @@ const HTML = `<!DOCTYPE html>
|
|
|
1007
1175
|
var steps = (isEn && info.steps_en) || info.steps;
|
|
1008
1176
|
var maskedVal = (data.masked && data.masked[envVar]) || '';
|
|
1009
1177
|
var stepsHtml = (steps || []).map(function (s) { return '<li>' + s + '</li>'; }).join('');
|
|
1178
|
+
var stClass = maskedVal ? 'status-ok' : 'status-wait';
|
|
1010
1179
|
html += '<div class="key-card" id="card-' + envVar + '">';
|
|
1011
|
-
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>';
|
|
1012
1181
|
html += '<div class="desc">' + esc(desc) + '</div>';
|
|
1013
1182
|
html += '<ol>' + stepsHtml + '</ol>';
|
|
1014
1183
|
html += '<div class="key-input-row">';
|
|
1015
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">';
|
|
1016
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>';
|
|
1017
1189
|
html += '</div>';
|
|
1018
|
-
html += '<div id="status-' + envVar + '" class="
|
|
1019
|
-
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>';
|
|
1020
1192
|
html += '</div>';
|
|
1021
1193
|
}
|
|
1022
1194
|
grid.innerHTML = html;
|
|
1023
1195
|
}
|
|
1024
1196
|
document.addEventListener('click', function (e) {
|
|
1025
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);
|
|
1026
1199
|
});
|
|
1027
1200
|
async function validateKey(envVar) {
|
|
1028
1201
|
var input = document.getElementById('input-' + envVar);
|
|
@@ -1043,6 +1216,28 @@ const HTML = `<!DOCTYPE html>
|
|
|
1043
1216
|
if (input && input.value.trim()) await validateKey(envVar);
|
|
1044
1217
|
}
|
|
1045
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
|
+
}
|
|
1046
1241
|
async function saveAllKeys() {
|
|
1047
1242
|
var status = document.getElementById('save-status');
|
|
1048
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/modelmanager.js
CHANGED
|
@@ -26,6 +26,13 @@ function inferSource(providerEntry) {
|
|
|
26
26
|
const env = providerEntry.envVar || '';
|
|
27
27
|
if (env.includes('OPENROUTER')) return 'openrouter';
|
|
28
28
|
if (env.includes('HF')) return 'huggingface';
|
|
29
|
+
if (env.includes('SAMBANOVA')) return 'sambanova';
|
|
30
|
+
if (env.includes('SILICONFLOW')) return 'siliconflow';
|
|
31
|
+
if (env.includes('DEEPINFRA')) return 'deepinfra';
|
|
32
|
+
if (env.includes('HYPERBOLIC')) return 'hyperbolic';
|
|
33
|
+
if (env.includes('COHERE')) return 'cohere';
|
|
34
|
+
if (env.includes('LLM7')) return 'llm7';
|
|
35
|
+
if (env.includes('NARA')) return 'nara';
|
|
29
36
|
const ep = providerEntry.endpoint || '';
|
|
30
37
|
if (ep.includes('api.groq.com')) return 'groq';
|
|
31
38
|
if (ep.includes('api.mistral.ai')) return 'mistral';
|
|
@@ -33,6 +40,13 @@ function inferSource(providerEntry) {
|
|
|
33
40
|
if (ep.includes('api.cerebras.ai')) return 'cerebras';
|
|
34
41
|
if (ep.includes('api.deepseek.com')) return 'deepseek';
|
|
35
42
|
if (ep.includes('integrate.api.nvidia')) return 'nim';
|
|
43
|
+
if (ep.includes('api.sambanova.ai')) return 'sambanova';
|
|
44
|
+
if (ep.includes('api.siliconflow.com')) return 'siliconflow';
|
|
45
|
+
if (ep.includes('api.deepinfra.com')) return 'deepinfra';
|
|
46
|
+
if (ep.includes('api.hyperbolic.xyz')) return 'hyperbolic';
|
|
47
|
+
if (ep.includes('api.cohere.com')) return 'cohere';
|
|
48
|
+
if (ep.includes('api.llm7.io')) return 'llm7';
|
|
49
|
+
if (ep.includes('router.bynara.id')) return 'nara';
|
|
36
50
|
return 'unknown';
|
|
37
51
|
}
|
|
38
52
|
|
package/lib/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",
|
|
@@ -634,5 +642,75 @@
|
|
|
634
642
|
"free": true,
|
|
635
643
|
"category": "general",
|
|
636
644
|
"context_window": 1000000
|
|
645
|
+
},
|
|
646
|
+
"gemini-gemma-4-26b-a4b-it": {
|
|
647
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
648
|
+
"model": "gemma-4-26b-a4b-it",
|
|
649
|
+
"priority": 5,
|
|
650
|
+
"dailyLimit": 1500,
|
|
651
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
652
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
653
|
+
"free": true,
|
|
654
|
+
"category": "general"
|
|
655
|
+
},
|
|
656
|
+
"gemini-gemma-4-31b-it": {
|
|
657
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
658
|
+
"model": "gemma-4-31b-it",
|
|
659
|
+
"priority": 1,
|
|
660
|
+
"dailyLimit": 1500,
|
|
661
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
662
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
663
|
+
"free": true,
|
|
664
|
+
"category": "general"
|
|
665
|
+
},
|
|
666
|
+
"gemini-gemini-flash-lite-latest": {
|
|
667
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
668
|
+
"model": "gemini-flash-lite-latest",
|
|
669
|
+
"priority": 2,
|
|
670
|
+
"dailyLimit": 1500,
|
|
671
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
672
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
673
|
+
"free": true,
|
|
674
|
+
"category": "general"
|
|
675
|
+
},
|
|
676
|
+
"gemini-gemini-3-1-flash-lite-preview": {
|
|
677
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
678
|
+
"model": "gemini-3.1-flash-lite-preview",
|
|
679
|
+
"priority": 6,
|
|
680
|
+
"dailyLimit": 1500,
|
|
681
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
682
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
683
|
+
"free": true,
|
|
684
|
+
"category": "general"
|
|
685
|
+
},
|
|
686
|
+
"gemini-gemini-3-5-flash": {
|
|
687
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
688
|
+
"model": "gemini-3.5-flash",
|
|
689
|
+
"priority": 3,
|
|
690
|
+
"dailyLimit": 1500,
|
|
691
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
692
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
693
|
+
"free": true,
|
|
694
|
+
"category": "general"
|
|
695
|
+
},
|
|
696
|
+
"gemini-gemini-3-5-flash-lite": {
|
|
697
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
698
|
+
"model": "gemini-3.5-flash-lite",
|
|
699
|
+
"priority": 4,
|
|
700
|
+
"dailyLimit": 1500,
|
|
701
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
702
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
703
|
+
"free": true,
|
|
704
|
+
"category": "general"
|
|
705
|
+
},
|
|
706
|
+
"gemini-gemini-3-1-flash-lite": {
|
|
707
|
+
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
708
|
+
"model": "gemini-3.1-flash-lite",
|
|
709
|
+
"priority": 7,
|
|
710
|
+
"dailyLimit": 1500,
|
|
711
|
+
"keyHint": "gemini → Keys (автодобавлено, general)",
|
|
712
|
+
"envVar": "PROVIDER_GEMINI_APIKEY",
|
|
713
|
+
"free": true,
|
|
714
|
+
"category": "general"
|
|
637
715
|
}
|
|
638
|
-
}
|
|
716
|
+
}
|
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 {
|
|
@@ -1253,6 +1282,57 @@ const server = http.createServer(async (req, res) => {
|
|
|
1253
1282
|
return;
|
|
1254
1283
|
}
|
|
1255
1284
|
|
|
1285
|
+
if (parsedUrl.pathname === '/v1/config') {
|
|
1286
|
+
// Чтение/запись опций оптимизации (compress/vetting/routing) в config.json.
|
|
1287
|
+
if (AUTH_KEY) {
|
|
1288
|
+
const apiKey = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
|
1289
|
+
const keyFromQuery = parsedUrl.searchParams.get('key');
|
|
1290
|
+
if (apiKey !== AUTH_KEY && keyFromQuery !== AUTH_KEY) {
|
|
1291
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1292
|
+
res.end(JSON.stringify({ error: { message: 'Invalid API key' } }));
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
try {
|
|
1297
|
+
const userCfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
1298
|
+
if (req.method === 'GET') {
|
|
1299
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1300
|
+
res.end(JSON.stringify({
|
|
1301
|
+
compress: { enabled: !!(userCfg.compress && userCfg.compress.enabled), minLen: userCfg.compress?.minLen || 60 },
|
|
1302
|
+
vetting: { enabled: !!(userCfg.vetting && userCfg.vetting.enabled), minAnswerLen: userCfg.vetting?.minAnswerLen || 120, complexityOnly: userCfg.vetting?.complexityOnly !== false },
|
|
1303
|
+
routing: { strategy: userCfg.routing?.strategy || 'weighted', preference: userCfg.routing?.preference || 'free-first' },
|
|
1304
|
+
}));
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
if (req.method === 'POST') {
|
|
1308
|
+
let body = '';
|
|
1309
|
+
req.on('data', (c) => { body += c; if (body.length > 100000) req.destroy(); });
|
|
1310
|
+
req.on('end', () => {
|
|
1311
|
+
try {
|
|
1312
|
+
const patch = JSON.parse(body || '{}');
|
|
1313
|
+
if (typeof patch.compress === 'object') userCfg.compress = Object.assign({ enabled: false, minLen: 60 }, userCfg.compress, patch.compress);
|
|
1314
|
+
if (typeof patch.vetting === 'object') userCfg.vetting = Object.assign({ enabled: false, minAnswerLen: 120, complexityOnly: true }, userCfg.vetting, patch.vetting);
|
|
1315
|
+
if (typeof patch.routing === 'object') userCfg.routing = Object.assign({ strategy: 'weighted', preference: 'free-first' }, userCfg.routing, patch.routing);
|
|
1316
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
|
|
1317
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1318
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1321
|
+
res.end(JSON.stringify({ error: { message: 'Invalid config body: ' + e.message } }));
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
1327
|
+
res.end(JSON.stringify({ error: { message: 'Method not allowed' } }));
|
|
1328
|
+
return;
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1331
|
+
res.end(JSON.stringify({ error: { message: 'Config read failed: ' + err.message } }));
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1256
1336
|
if (parsedUrl.pathname === '/v1/models-db' && req.method === 'GET') {
|
|
1257
1337
|
// Структурированная база моделей: паспорта + статистика + топ по скору.
|
|
1258
1338
|
if (AUTH_KEY) {
|
|
@@ -1384,6 +1464,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1384
1464
|
}
|
|
1385
1465
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1386
1466
|
res.end(JSON.stringify({
|
|
1467
|
+
version: VERSION,
|
|
1387
1468
|
total_requests: s.totalRequests, successful_requests: s.successfulRequests, failed_requests: s.failedRequests,
|
|
1388
1469
|
provider_usage: s.providerUsage, token_usage: s.tokenUsage, errors: s.errors, uptime_seconds: Math.floor((Date.now() - s.startTime) / 1000),
|
|
1389
1470
|
today: (() => {
|
|
@@ -1659,6 +1740,11 @@ server.listen(PORT, process.env.HOST || '127.0.0.1', () => {
|
|
|
1659
1740
|
modelManager.start();
|
|
1660
1741
|
logger.info('ModelManager started', { intervalHours: modelManager.config.intervalHours });
|
|
1661
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
|
+
}
|
|
1662
1748
|
});
|
|
1663
1749
|
|
|
1664
1750
|
const _shutdown = () => {
|