freegate 0.6.18 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -1
- package/README.md +9 -4
- package/README.ru.md +9 -3
- package/assets/dashboard-en.gif +0 -0
- package/assets/dashboard-en.png +0 -0
- package/assets/dashboard-models-en.png +0 -0
- package/assets/dashboard.gif +0 -0
- package/bin/freegate.js +107 -0
- package/config.example.json +33 -0
- package/lib/cache.js +19 -4
- package/lib/compress.js +85 -0
- package/lib/dashboard.js +82 -11
- package/lib/doctor.js +106 -0
- package/lib/health.js +48 -0
- package/lib/modelscan.js +7 -2
- package/lib/onboarding.js +79 -0
- package/lib/setup.js +136 -0
- package/lib/strategy.js +57 -0
- package/lib/vetting.js +91 -0
- package/package.json +1 -1
- package/providers.json +264 -0
- package/server.js +69 -6
package/.env.example
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
# Copy to .env and fill in your keys. Only
|
|
1
|
+
# Copy to .env and fill in your keys. Only free:true providers are required.
|
|
2
|
+
# Бесплатные провайдеры free-model (несколько ключей = больше пул + надёжность):
|
|
2
3
|
PROVIDER_GROQ_APIKEY=
|
|
3
4
|
PROVIDER_MISTRAL_APIKEY=
|
|
4
5
|
PROVIDER_GEMINI_APIKEY=
|
|
5
6
|
PROVIDER_NIM_APIKEY=
|
|
6
7
|
PROVIDER_ZAI_APIKEY=
|
|
7
8
|
PROVIDER_OPENROUTER_APIKEY=
|
|
9
|
+
PROVIDER_CEREBRAS_APIKEY=
|
|
10
|
+
PROVIDER_DEEPSEEK_APIKEY=
|
|
11
|
+
PROVIDER_HF_APIKEY=
|
|
12
|
+
# Розширений пул (бесплатные free-тиры, добавить по желанию):
|
|
13
|
+
PROVIDER_SAMBANOVA_APIKEY=
|
|
14
|
+
PROVIDER_SILICONFLOW_APIKEY=
|
|
15
|
+
PROVIDER_DEEPINFRA_APIKEY=
|
|
16
|
+
PROVIDER_HYPERBOLIC_APIKEY=
|
|
17
|
+
PROVIDER_COHERE_APIKEY=
|
|
18
|
+
PROVIDER_LLM7_APIKEY=
|
|
19
|
+
PROVIDER_NARA_APIKEY=
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
## Why pay for LLMs when free ones exist?
|
|
15
15
|
|
|
16
16
|
Your AI agent, bot, or script talks to a **single OpenAI-compatible endpoint**.
|
|
17
|
-
Behind it, Freegate automatically routes requests across **
|
|
17
|
+
Behind it, Freegate automatically routes requests across **50+ free models**
|
|
18
18
|
from Groq, Mistral, Gemini, NVIDIA NIM, OpenRouter, ZAI, Cerebras, DeepSeek
|
|
19
19
|
and local models. If one provider goes down, gets overloaded, or burns its
|
|
20
20
|
daily limit — the request **instantly falls through to the next one**. You
|
|
@@ -29,27 +29,31 @@ is only yours — nobody else can connect to it, and you can't to theirs. Provid
|
|
|
29
29
|
keys live in your local `.env`, history in local files. **No third-party server
|
|
30
30
|
sees your keys or conversations.**
|
|
31
31
|
|
|
32
|
-

|
|
32
|
+

|
|
33
33
|
|
|
34
34
|
## Features
|
|
35
35
|
|
|
36
36
|
| | |
|
|
37
37
|
|---|---|
|
|
38
|
-
| 🔀 **Auto-failover** |
|
|
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
40
|
| 🗂️ **Model database** | Structured passport per model (score, latency, context window, history) + sorting: best models get routing priority. |
|
|
41
41
|
| 🏷️ **Model categories** | reasoning / coding / general / vision / local — the right model for the right job. |
|
|
42
42
|
| 🖼️ **Two-stage vision** | Screenshot → vision model reads it → coding model answers the fix. |
|
|
43
43
|
| 💰 **Free** | Free models only. The dashboard shows each provider's remaining limit. |
|
|
44
44
|
| ⚡ **Smart routing** | Picks the fastest, most stable provider for every request. |
|
|
45
|
+
| 🎯 **Routing strategies** | `weighted` (default), `weighted-roundrobin`, `weighted-least` — burn limits more evenly. |
|
|
45
46
|
| 🛡️ **Reliability** | Circuit breaker, request queue, auto-disable of dead providers, watchdog. |
|
|
46
47
|
| 📊 **Dashboard** | Status, speed, limits, history, tokens, RPM chart, savings ($). RU/EN. **4 themes** (PolyCopy/Warm/Cosmic/Paper) — switcher in the header. |
|
|
47
48
|
| 💾 **Disk cache** | Repeat prompts don't consume limits at all. |
|
|
48
49
|
| 🎓 **Methodologist** | Agent answers like an engineer: plan→test→code (coding), stepwise (reasoning), like a frontend designer (design). Prompts customizable in `config.json`. |
|
|
49
50
|
| 🌐 **Web search** | For question/answer it fetches current facts from the web (DuckDuckGo, keyless) — answers accurately instead of hallucinating. |
|
|
51
|
+
| 🧪 **Self-check** | Optional: complex answers are vetted by a second model (vetting) and flagged on error. Enable: `config.vetting.enabled`. |
|
|
52
|
+
| ✂️ **Prompt compression** | Optional: strips politeness/fillers (Caveman-style) to save tokens. `config.compress.enabled`. |
|
|
53
|
+
| 📈 **Sparkline 24h** | Hourly success in the dashboard + history filters (search by model/provider, OK/Errors). |
|
|
50
54
|
| 🔌 **Compatible** | Any OpenAI client: opencode, Cursor, your scripts. |
|
|
51
55
|
|
|
52
|
-

|
|
56
|
+

|
|
53
57
|
|
|
54
58
|
## Quick start — 30 seconds
|
|
55
59
|
|
|
@@ -220,6 +224,7 @@ put it in `config.json` → `providers` (same format as `providers.json`).
|
|
|
220
224
|
```bash
|
|
221
225
|
npx freegate init # create config
|
|
222
226
|
npx freegate init -i # interactive wizard (keys, password)
|
|
227
|
+
npx freegate doctor # diagnostics: keys, models, "what to check"
|
|
223
228
|
npx freegate start # start proxy
|
|
224
229
|
npx freegate status # diagnostics: providers, limits, errors
|
|
225
230
|
npx freegate test # verify it works
|
package/README.ru.md
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
## Зачем платить за LLM, когда есть бесплатные?
|
|
14
14
|
|
|
15
15
|
Твой AI-агент, бот или скрипт использует **один OpenAI-совместимый endpoint**.
|
|
16
|
-
За ним Freegate автоматически распределяет запросы между **
|
|
16
|
+
За ним Freegate автоматически распределяет запросы между **50+ бесплатными
|
|
17
17
|
моделями** — Groq, Mistral, Gemini, NVIDIA NIM, OpenRouter, ZAI, Cerebras,
|
|
18
|
-
DeepSeek
|
|
18
|
+
DeepSeek, SambaNova, SiliconFlow, DeepInfra, Hyperbolic, Cohere, LLM7, Nara
|
|
19
|
+
и локальные модели. Если один провайдер упал, перегружен или сжёг
|
|
19
20
|
дневной лимит — запрос **мгновенно уходит на следующий**. Ты никогда не
|
|
20
21
|
видишь «rate limit», и никогда не платишь.
|
|
21
22
|
|
|
@@ -34,18 +35,22 @@ Freegate крутится **на твоей машине** (`http://localhost:40
|
|
|
34
35
|
|
|
35
36
|
| | |
|
|
36
37
|
|---|---|
|
|
37
|
-
| 🔀 **Автопереключение** |
|
|
38
|
+
| 🔀 **Автопереключение** | 50+ провайдеров в одной цепочке. Провайдер упал? Следующий уже отвечает. |
|
|
38
39
|
| 🤖 **Автоуправление моделями** | Сам находит новые бесплатные модели, тестирует, добавляет рабочие, отключает мёртвые — встроенный планировщик, всегда актуальная база. |
|
|
39
40
|
| 🗂️ **База моделей** | Структурированный паспорт каждой модели (скор, латентность, окно, история) + сортировка: лучшие модели получают приоритет в роутинге. |
|
|
40
41
|
| 🏷️ **Категории моделей** | reasoning / coding / general / vision / local — правильная модель для каждой задачи. |
|
|
41
42
|
| 🖼️ **Vision-конвейер** | Скриншот → vision-модель читает → кодинг-модель отвечает на вопрос. |
|
|
42
43
|
| 💰 **Бесплатно** | Только free-модели. Дашборд показывает остаток лимита каждого провайдера. |
|
|
43
44
|
| ⚡ **Умный выбор** | Прокси сам находит самый быстрый и стабильный провайдер для каждого запроса. |
|
|
45
|
+
| 🎯 **Стратегии роутинга** | `weighted` (по умолчанию), `weighted-roundrobin`, `weighted-least` — ровнее расходуют лимиты. |
|
|
44
46
|
| 🛡️ **Надёжность** | Circuit breaker, очередь запросов, автоотключение мёртвых провайдеров, watchdog. |
|
|
45
47
|
| 📊 **Дашборд** | Статус, скорость, лимиты, история, токены, RPM-график, экономия ($). RU/EN. **4 темы** (PolyCopy/Тёплый/Космос/Бумага) — переключатель в шапке. |
|
|
46
48
|
| 💾 **Кэш на диске** | Повторные промпты не тратят лимиты вообще. |
|
|
47
49
|
| 🎓 **Методолог** | Агент отвечает как инженер: план→тест→код (coding), пошагово (reasoning), **как фронтенд-дизайнер** (design). Промпты настраиваются в `config.json`. |
|
|
48
50
|
| 🌐 **Веб-поиск** | Для вопросов-поиска Freegate находит актуальные факты в интернете (DuckDuckGo, без ключа) — отвечает по существу, а не галлюцинирует. |
|
|
51
|
+
| 🧪 **Самопроверка** | Опционально: сложные ответы проверяет второй моделью (vetting) и помечает ошибки. Вкл: `config.vetting.enabled`. |
|
|
52
|
+
| ✂️ **Сжатие промптов** | Опционально: убирает вежливость/заполнители (Caveman-стиль), экономя токены. `config.compress.enabled`. |
|
|
53
|
+
| 📈 **Sparkline 24ч** | Успех по часам в дашборде + фильтры истории (поиск по модели/провайдеру, OK/Ошибки). |
|
|
49
54
|
| 🔌 **Совместимость** | Любой OpenAI-клиент: opencode, Cursor, ChatGPT-аналоги, твои скрипты. |
|
|
50
55
|
|
|
51
56
|

|
|
@@ -241,6 +246,7 @@ PROVIDER_ZAI_APIKEY=... # ZAI
|
|
|
241
246
|
```bash
|
|
242
247
|
npx freegate init # создать конфиг
|
|
243
248
|
npx freegate init -i # интерактивный мастер (ключи, пароль)
|
|
249
|
+
npx freegate doctor # диагностика: ключи, модели, «что проверить»
|
|
244
250
|
npx freegate start # запустить прокси
|
|
245
251
|
npx freegate status # диагностика: провайдеры, лимиты, ошибки
|
|
246
252
|
npx freegate test # проверить, что работает
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/assets/dashboard.gif
CHANGED
|
Binary file
|
package/bin/freegate.js
CHANGED
|
@@ -125,6 +125,78 @@ if (cmd === 'init') {
|
|
|
125
125
|
console.log('\nГотово! Заполни .env ключами, затем: npx freegate start');
|
|
126
126
|
console.log('Совет: npx freegate init -i — интерактивный мастер с вопросами.');
|
|
127
127
|
}
|
|
128
|
+
} else if (cmd === 'doctor') {
|
|
129
|
+
const doctor = require(path.join(ROOT, 'lib', 'doctor'));
|
|
130
|
+
const setup = require(path.join(ROOT, 'lib', 'setup'));
|
|
131
|
+
|
|
132
|
+
console.log('\n🩺 Freegate doctor');
|
|
133
|
+
console.log('────────────────────');
|
|
134
|
+
|
|
135
|
+
const snap = doctor.snapshot();
|
|
136
|
+
|
|
137
|
+
// 1. Ключи — что задано.
|
|
138
|
+
const keyGroups = Object.values(snap.keyState);
|
|
139
|
+
const withKey = keyGroups.filter((k) => k.hasKey);
|
|
140
|
+
const noKey = keyGroups.filter((k) => !k.hasKey);
|
|
141
|
+
console.log(`Провайдеров в каталоге: ${snap.catalogCount}, групп провайдеров: ${keyGroups.length}`);
|
|
142
|
+
console.log(`Ключей задано: ${withKey.length}/${keyGroups.length}`);
|
|
143
|
+
if (withKey.length) {
|
|
144
|
+
console.log('\n✅ Ключи установлены:');
|
|
145
|
+
for (const k of withKey) {
|
|
146
|
+
const masked = k.key.slice(0, 4) + '…' + (k.key.length > 8 ? k.key.slice(-4) : '');
|
|
147
|
+
console.log(` ${k.name.padEnd(18)} (${k.count.toString().padStart(2)} мод.) ${masked}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (noKey.length) {
|
|
151
|
+
console.log('\n⬜ Ключи не заданы (модели недоступны):');
|
|
152
|
+
for (const k of noKey) console.log(` ${k.name.padEnd(18)} (${k.count.toString().padStart(2)} мод.)`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. Модели — живое.
|
|
156
|
+
const models = Object.keys(snap.models).length;
|
|
157
|
+
console.log(`\nМоделей в базе: ${models}`);
|
|
158
|
+
console.log(` активных: ${snap.byStatus.active} · отключено: ${snap.byStatus.disabled} · неизвестно: ${snap.byStatus.unknown}`);
|
|
159
|
+
if (snap.byStatus.active === 0 && models === 0) {
|
|
160
|
+
console.log(' ⚠️ База пуста — запусти сервер, чтобы автопоиск нашёл модели.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. Рекомендации «что включить».
|
|
164
|
+
const rec = doctor.recommend(snap.models);
|
|
165
|
+
if (rec.disabledByUser.length || rec.paid.length || rec.local.length || rec.dead.length) {
|
|
166
|
+
console.log('\n🔮 Что проверить:');
|
|
167
|
+
if (rec.disabledByUser.length) console.log(' ✅ Выключенные free (можно вернуть): ' + rec.disabledByUser.join(', '));
|
|
168
|
+
if (rec.paid.length) console.log(' 🟠 Платные/малый лимит (держать выключенными): ' + rec.paid.join(', '));
|
|
169
|
+
if (rec.local.length) console.log(' 🏠 Локальные (слабый Mac): ' + rec.local.join(', '));
|
|
170
|
+
if (rec.dead.length) console.log(' ⚠️ Мёртвые/непроверенные (чистить): ' + rec.dead.join(', '));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 4. Быстрый старт.
|
|
174
|
+
const easy = doctor.easyStartKey();
|
|
175
|
+
const easyHas = snap.keyState[easy.envVar] && snap.keyState[easy.envVar].hasKey;
|
|
176
|
+
if (!easyHas) {
|
|
177
|
+
console.log(`\n💡 Быстрый старт: добавь ключ ${easy.name} — сразу ${easy.count}+ бесплатных моделей.`);
|
|
178
|
+
console.log(' `npx freegate init -i` или дашборд → Настройки.');
|
|
179
|
+
} else {
|
|
180
|
+
console.log(`\n💡 Всё настроено. Запуск: npx freegate start · дашборд: http://localhost:4000`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Опциональная валидация ключей (медленная — по флагу).
|
|
184
|
+
if (process.argv.includes('--validate')) {
|
|
185
|
+
console.log('\n⏳ Проверяю ключи (может занять ~10с)...');
|
|
186
|
+
let n = 0;
|
|
187
|
+
(async () => {
|
|
188
|
+
for (const k of keyGroups) {
|
|
189
|
+
if (!k.hasKey) continue;
|
|
190
|
+
const r = await setup.validateKey(k.envVar, k.key);
|
|
191
|
+
n++;
|
|
192
|
+
if (r.valid) console.log(` ✅ ${k.name}`);
|
|
193
|
+
else console.log(` ❌ ${k.name}: ${r.error || 'неверный'}`);
|
|
194
|
+
}
|
|
195
|
+
process.exit(0);
|
|
196
|
+
})();
|
|
197
|
+
} else if (cmd === 'doctor') {
|
|
198
|
+
// (validate-ветка уже вышла через process.exit выше — здесь просто тишина)
|
|
199
|
+
}
|
|
128
200
|
} else if (cmd === 'start') {
|
|
129
201
|
// Spawn from the user's cwd (not package dir) so server.js picks up their
|
|
130
202
|
// config.json / .env created by `init`.
|
|
@@ -200,6 +272,41 @@ WantedBy=default.target
|
|
|
200
272
|
console.log(' ?theme=cosmic — открыть дашборд в теме вручную');
|
|
201
273
|
console.log(' Кнопка «Тема» в шапке дашборда — переключать по кругу');
|
|
202
274
|
console.log(' Памятка: `--theme` не нужен, тема хранится в localStorage браузера.');
|
|
275
|
+
} else if (cmd === 'connect') {
|
|
276
|
+
const onboarding = require(path.join(ROOT, 'lib', 'onboarding'));
|
|
277
|
+
const setup = require(path.join(ROOT, 'lib', 'setup'));
|
|
278
|
+
const { keys } = setup.readKeys();
|
|
279
|
+
let AUTH = keys.AUTH || process.env.AUTH || '';
|
|
280
|
+
if (!AUTH) {
|
|
281
|
+
try { AUTH = (JSON.parse(fs.readFileSync(path.join(process.cwd(), 'config.json'), 'utf8')).auth) || ''; } catch {}
|
|
282
|
+
}
|
|
283
|
+
const port = process.env.PORT || '4000';
|
|
284
|
+
const baseUrl = `http://localhost:${port}`;
|
|
285
|
+
const apiKey = AUTH || 'твой_пароль';
|
|
286
|
+
|
|
287
|
+
console.log('\n🔌 Подключить Freegate к клиенту');
|
|
288
|
+
console.log('────────────────────────────────');
|
|
289
|
+
console.log(`Base URL: ${baseUrl}/v1`);
|
|
290
|
+
|
|
291
|
+
// Определяем, какой профиль выбрать: смотрим, какие модели уже активны.
|
|
292
|
+
const doctor = require(path.join(ROOT, 'lib', 'doctor'));
|
|
293
|
+
const snap = doctor.snapshot();
|
|
294
|
+
const rec = doctor.recommend(snap.models);
|
|
295
|
+
const activeCount = snap.byStatus.active || 0;
|
|
296
|
+
|
|
297
|
+
console.log('\nВыбери профиль (подсказка по активным моделям):');
|
|
298
|
+
for (const p of onboarding.profiles()) {
|
|
299
|
+
console.log(` ${p.id.padEnd(11)} ${p.name} — ${p.hint}`);
|
|
300
|
+
}
|
|
301
|
+
console.log('\nПрофиль не влияет на прокси — это подсказка, какую модель выбрать в клиенте.');
|
|
302
|
+
|
|
303
|
+
console.log('\n─── Cursor ───');
|
|
304
|
+
console.log(onboarding.snippetCursor(baseUrl, apiKey));
|
|
305
|
+
console.log('\n─── opencode ───');
|
|
306
|
+
console.log(onboarding.snippetOpencode(baseUrl, apiKey));
|
|
307
|
+
console.log('\n─── Cline (VS Code) ───');
|
|
308
|
+
console.log(onboarding.snippetCline(baseUrl, apiKey));
|
|
309
|
+
console.log('\nСовет: модели с категорией coding → coder, design → designer и т.д.');
|
|
203
310
|
} else if (cmd === 'test') {
|
|
204
311
|
const http = require('http');
|
|
205
312
|
const base = `http://127.0.0.1:${process.env.PORT || 4000}`;
|
package/config.example.json
CHANGED
|
@@ -22,6 +22,19 @@
|
|
|
22
22
|
"timeout": 8000,
|
|
23
23
|
"queryMinChars": 6
|
|
24
24
|
},
|
|
25
|
+
"vetting": {
|
|
26
|
+
"enabled": false,
|
|
27
|
+
"minAnswerLen": 120,
|
|
28
|
+
"maxChecksPerMin": 4,
|
|
29
|
+
"complexityOnly": true
|
|
30
|
+
},
|
|
31
|
+
"routing": {
|
|
32
|
+
"strategy": "weighted"
|
|
33
|
+
},
|
|
34
|
+
"compress": {
|
|
35
|
+
"enabled": false,
|
|
36
|
+
"minLen": 60
|
|
37
|
+
},
|
|
25
38
|
"providers": {
|
|
26
39
|
"or-nemotron-550b": {
|
|
27
40
|
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
|
|
@@ -31,6 +44,26 @@
|
|
|
31
44
|
"keyHint": "openrouter.ai → Keys",
|
|
32
45
|
"envVar": "PROVIDER_OPENROUTER_APIKEY",
|
|
33
46
|
"free": true
|
|
47
|
+
},
|
|
48
|
+
"sambanova-gpt-oss-120b": {
|
|
49
|
+
"endpoint": "https://api.sambanova.ai/v1/chat/completions",
|
|
50
|
+
"model": "gpt-oss-120b",
|
|
51
|
+
"priority": 12,
|
|
52
|
+
"dailyLimit": 100000,
|
|
53
|
+
"keyHint": "cloud.sambanova.ai → API Keys",
|
|
54
|
+
"envVar": "PROVIDER_SAMBANOVA_APIKEY",
|
|
55
|
+
"free": true,
|
|
56
|
+
"category": "general"
|
|
57
|
+
},
|
|
58
|
+
"siliconflow-deepseek-r1": {
|
|
59
|
+
"endpoint": "https://api.siliconflow.com/v1/chat/completions",
|
|
60
|
+
"model": "deepseek-ai/DeepSeek-R1",
|
|
61
|
+
"priority": 12,
|
|
62
|
+
"dailyLimit": 100000,
|
|
63
|
+
"keyHint": "cloud.siliconflow.com → API Keys",
|
|
64
|
+
"envVar": "PROVIDER_SILICONFLOW_APIKEY",
|
|
65
|
+
"free": true,
|
|
66
|
+
"category": "reasoning"
|
|
34
67
|
}
|
|
35
68
|
}
|
|
36
69
|
}
|
package/lib/cache.js
CHANGED
|
@@ -45,7 +45,7 @@ class LRUCache {
|
|
|
45
45
|
const now = Date.now();
|
|
46
46
|
for (const e of data.entries) {
|
|
47
47
|
if (now - e.created > this.ttl) continue;
|
|
48
|
-
this.cache.set(e.key, { value: e.value, created: e.created, uses: e.uses || 0, model: e.model, temperature: e.temperature, grams: e.grams || null });
|
|
48
|
+
this.cache.set(e.key, { value: e.value, created: e.created, uses: e.uses || 0, model: e.model, temperature: e.temperature, grams: e.grams || null, providerKey: e.providerKey || null });
|
|
49
49
|
}
|
|
50
50
|
// Restore hit/miss counters so hitRate survives restarts
|
|
51
51
|
if (typeof data.hits === 'number') this.hits = data.hits;
|
|
@@ -63,7 +63,7 @@ if (now - e.created > this.ttl) continue;
|
|
|
63
63
|
try {
|
|
64
64
|
const size = Buffer.byteLength(JSON.stringify(entry.value));
|
|
65
65
|
if (size > MAX_ENTRY_BYTES) continue;
|
|
66
|
-
entries.push({ key, value: entry.value, created: entry.created, uses: entry.uses || 0, model: entry.model, temperature: entry.temperature, grams: entry.grams || null });
|
|
66
|
+
entries.push({ key, value: entry.value, created: entry.created, uses: entry.uses || 0, model: entry.model, temperature: entry.temperature, grams: entry.grams || null, providerKey: entry.providerKey || null });
|
|
67
67
|
} catch {}
|
|
68
68
|
}
|
|
69
69
|
// Keep only the most recent 300 on disk to bound file size
|
|
@@ -141,7 +141,7 @@ if (now - e.created > this.ttl) continue;
|
|
|
141
141
|
return { value: entry.value, similarity: Math.round(bestSim * 1000) / 1000 };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
set(model, messages, temperature, value) {
|
|
144
|
+
set(model, messages, temperature, value, providerKey) {
|
|
145
145
|
const key = this._key(model, messages, temperature);
|
|
146
146
|
|
|
147
147
|
// Delete if exists (to update order)
|
|
@@ -167,10 +167,24 @@ if (now - e.created > this.ttl) continue;
|
|
|
167
167
|
grams = new Set(trigrams(norm));
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
this.cache.set(key, { value, created: Date.now(), uses: 0, model, temperature, grams: grams ? [...grams] : null });
|
|
170
|
+
this.cache.set(key, { value, created: Date.now(), uses: 0, model, temperature, grams: grams ? [...grams] : null, providerKey: providerKey || null });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Вернуть провайдер-источник кэш-записи (для диагностики: кэш-хит может
|
|
174
|
+
// скрывать реальный источник ответа). null → записи нет/без провайдера.
|
|
175
|
+
getProvider(model, messages, temperature) {
|
|
176
|
+
const key = this._key(model, messages, temperature);
|
|
177
|
+
const entry = this.cache.get(key);
|
|
178
|
+
return entry ? (entry.providerKey || null) : null;
|
|
171
179
|
}
|
|
172
180
|
|
|
173
181
|
stats() {
|
|
182
|
+
// Распределение кэш-записей по провайдерам-источникам (для дашборда).
|
|
183
|
+
const byProvider = {};
|
|
184
|
+
for (const entry of this.cache.values()) {
|
|
185
|
+
const pk = entry.providerKey || 'unknown';
|
|
186
|
+
byProvider[pk] = (byProvider[pk] || 0) + 1;
|
|
187
|
+
}
|
|
174
188
|
return {
|
|
175
189
|
hits: this.hits,
|
|
176
190
|
misses: this.misses,
|
|
@@ -180,6 +194,7 @@ if (now - e.created > this.ttl) continue;
|
|
|
180
194
|
hitRate: this.hits + this.misses > 0
|
|
181
195
|
? Math.round((this.hits / (this.hits + this.misses)) * 100)
|
|
182
196
|
: 0,
|
|
197
|
+
byProvider,
|
|
183
198
|
};
|
|
184
199
|
}
|
|
185
200
|
|
package/lib/compress.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// lib/compress.js — лёгкое сжатие промпта (по мотивам OmniRoute Caveman).
|
|
2
|
+
// Удаляет вежливость/хеджирование/заполнители из ПОСЛЕДНЕГО user-сообщения,
|
|
3
|
+
// экономя токены free-лимитов. Только для plain-text сообщений; код/контент-
|
|
4
|
+
// массивы/инструменты не трогаем. Опционально: config.compress.enabled.
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
const COMPRESS_DEFAULTS = { enabled: false, minLen: 60 };
|
|
8
|
+
|
|
9
|
+
// Пары «регэксп → замена». Намеренно бережно: не ломаем смысл, только пустые
|
|
10
|
+
// фразы. Артикли EN убираем только когда после них буква (не ломать код/URL).
|
|
11
|
+
const RULES = [
|
|
12
|
+
// English pleasantries / polite framing / hedging / fillers
|
|
13
|
+
[/^(?:sure|certainly|of course|happy to|absolutely)\s*[,!.]?\s+/i, ''],
|
|
14
|
+
[/^(?:thanks|thank you|thanks in advance|i really appreciate)\s*[,!.]?\s+/i, ''],
|
|
15
|
+
[/^(?:please|kindly|could you please|would you please|can you please)\s+/i, ''],
|
|
16
|
+
[/^(?:i want you to|i need you to|i'd like you to)\s+/i, ''],
|
|
17
|
+
[/^(?:i am trying to|i am working on|i have been)\s+/i, ''],
|
|
18
|
+
[/\b(?:it seems like|it appears that|i think that|i believe that|probably|possibly|maybe it)\s+/gi, ''],
|
|
19
|
+
[/\b(?:basically|essentially|actually|literally|simply|currently|just)\s+/gi, ''],
|
|
20
|
+
[/\b(?:i want to|i need to|i'd like to|i'm looking for)\s+/gi, ''],
|
|
21
|
+
[/^(?:hi there|hello|hey|good morning|good afternoon)\s*[,!.]?\s+/i, ''],
|
|
22
|
+
[/\b(?:a bit|a little|somewhat|kind of|sort of)\s+/gi, ''],
|
|
23
|
+
// English hedgy / filler requests
|
|
24
|
+
[/\b(?:i was wondering|would it be possible|if possible|when you get a chance|at your convenience)\s*,?\s+/gi, ''],
|
|
25
|
+
// English articles (only plain prose, not URLs/code)
|
|
26
|
+
[/\b(?:an|a|the)\s+(?=[a-z])/gi, ''],
|
|
27
|
+
// Russian fillers and polite framing
|
|
28
|
+
[/^(?:пожалуйста|будьте добры|если можно|не могли бы вы)\s*[,!.]?\s+/i, ''],
|
|
29
|
+
[/^(?:привет|здравствуйте|добрый день|добрый вечер)\s*[,!.]?\s+/i, ''],
|
|
30
|
+
[/\b(?:просто|кстати|вообще|скажем|по сути|по-хорошему|честно говоря)\s+/gi, ''],
|
|
31
|
+
[/\b(?:как бы|вроде бы|в принципе|наверное|возможно|пожалуй)\s+/gi, ''],
|
|
32
|
+
[/^(?:я хочу|мне нужно|я бы хотел|я пытаюсь|я работаю)\s+/i, ''],
|
|
33
|
+
[/\b(?:спасибо|заранее спасибо|очень благодарен)\s*[,!.]?\s+/gi, ''],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Не сжимаем, если сообщение похоже на код.
|
|
37
|
+
function looksLikeCode(text) {
|
|
38
|
+
return /[{}\[\]]|=>|\bdef\b|\bfunction\b|\bconst\b|\bimport\b|\bfrom\b\s+["']|<\/?[a-z][^>]*>/i.test(text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Убрать «шапку» polite-prose из текста.
|
|
42
|
+
function compressText(text) {
|
|
43
|
+
if (!text || typeof text !== 'string') return text;
|
|
44
|
+
let out = text;
|
|
45
|
+
for (const [re, rep] of RULES) {
|
|
46
|
+
out = out.replace(re, rep);
|
|
47
|
+
}
|
|
48
|
+
// подчистить возможные двойные пробелы после сокращений
|
|
49
|
+
out = out.replace(/[ \t]{2,}/g, ' ').trim();
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function shouldCompress(cfg, text) {
|
|
54
|
+
if (!cfg || cfg.enabled === false) return false;
|
|
55
|
+
if (!text || typeof text !== 'string') return false;
|
|
56
|
+
if (text.trim().length < (cfg.minLen || COMPRESS_DEFAULTS.minLen)) return false;
|
|
57
|
+
if (looksLikeCode(text)) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Сжать последний user-message (только plain string content).
|
|
62
|
+
function compressMessages(messages, options = {}) {
|
|
63
|
+
const cfg = Object.assign({}, COMPRESS_DEFAULTS, options);
|
|
64
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) return messages;
|
|
65
|
+
if (cfg.enabled === false) return messages;
|
|
66
|
+
|
|
67
|
+
let mutated = false;
|
|
68
|
+
const copy = messages.slice();
|
|
69
|
+
for (let i = copy.length - 1; i >= 0; i--) {
|
|
70
|
+
const m = copy[i];
|
|
71
|
+
if (!m || m.role !== 'user') continue;
|
|
72
|
+
if (typeof m.content !== 'string') continue; // не трогаем массивы/инструменты
|
|
73
|
+
if (!shouldCompress(cfg, m.content)) continue;
|
|
74
|
+
const compressed = compressText(m.content);
|
|
75
|
+
if (compressed !== m.content && compressed.length > 0) {
|
|
76
|
+
copy[i] = { ...m, content: compressed };
|
|
77
|
+
mutated = true;
|
|
78
|
+
break; // только последнее user-сообщение
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (mutated) logger.info('prompt compressed', { freed: 0 });
|
|
82
|
+
return mutated ? copy : messages;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { compressText, compressMessages, shouldCompress, looksLikeCode, RULES, COMPRESS_DEFAULTS };
|
package/lib/dashboard.js
CHANGED
|
@@ -279,7 +279,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
279
279
|
|
|
280
280
|
<div id="main">
|
|
281
281
|
<header id="topbar">
|
|
282
|
-
<div class="brand"><span id="statusDot" class="dot"></span
|
|
282
|
+
<div class="brand"><span id="statusDot" class="dot"></span><span data-i18n="topTitle"></span></div>
|
|
283
283
|
<div class="theme-switch" title="">
|
|
284
284
|
<button class="theme-btn" id="themeBtn" onclick="cycleTheme()"></button>
|
|
285
285
|
</div>
|
|
@@ -307,9 +307,21 @@ const HTML = `<!DOCTYPE html>
|
|
|
307
307
|
<div class="card"><h2 data-i18n="cardPool"></h2><div id="pool" data-i18n="loading"></div></div>
|
|
308
308
|
<div class="card"><h2 data-i18n="cardSavings"></h2><div id="savings" data-i18n="loading"></div></div>
|
|
309
309
|
</div>
|
|
310
|
-
<div class="
|
|
310
|
+
<div class="grid">
|
|
311
|
+
<div class="card"><h2 data-i18n="cardRpm"></h2><canvas id="rpmChart"></canvas></div>
|
|
312
|
+
<div class="card"><h2 data-i18n="cardSpark"></h2><canvas id="sparkline"></canvas></div>
|
|
313
|
+
</div>
|
|
311
314
|
<div class="card" style="margin-top:16px;">
|
|
312
315
|
<h2 data-i18n="cardRecent"></h2>
|
|
316
|
+
<div class="toolbar">
|
|
317
|
+
<input id="recentSearch" class="search" type="text" placeholder="" data-i18n-ph="searchRecent" oninput="renderRecent(_recent)">
|
|
318
|
+
<select id="recentStatusFilter" class="filter" onchange="renderRecent(_recent)">
|
|
319
|
+
<option value="all" data-i18n="filterAll"></option>
|
|
320
|
+
<option value="ok" data-i18n="recentOk"></option>
|
|
321
|
+
<option value="err" data-i18n="recentErr"></option>
|
|
322
|
+
</select>
|
|
323
|
+
<span class="count-hint" id="recentCount"></span>
|
|
324
|
+
</div>
|
|
313
325
|
<div class="table-wrap"><table id="recentTable"></table></div>
|
|
314
326
|
</div>
|
|
315
327
|
</section>
|
|
@@ -383,18 +395,19 @@ const HTML = `<!DOCTYPE html>
|
|
|
383
395
|
ru: {
|
|
384
396
|
tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
|
|
385
397
|
tabContext: 'Контекст', tabSetup: 'Настройки (ключи)',
|
|
386
|
-
crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate',
|
|
398
|
+
crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate', topTitle: 'Панель управления',
|
|
387
399
|
kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
|
|
388
400
|
kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
|
|
389
401
|
cardSavings: 'Экономия (против платных API)', savings: 'Сэкономлено', tokens: 'токенов',
|
|
390
402
|
savingsBasis: 'расчёт: input $3/M, output $15/M (репрезентативно)',
|
|
391
403
|
cardCache: 'Кэш', cardTokens: 'Токены', cardPool: 'Очередь',
|
|
392
|
-
cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы',
|
|
404
|
+
cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы', cardSpark: 'Успех сегодня (по часам)',
|
|
393
405
|
cardCtx: 'Контекст (узкое место)', cardTasks: 'Категории задач (24ч)',
|
|
394
406
|
loading: 'Загрузка...', noData: 'Нет данных',
|
|
395
407
|
cacheHits: 'Хиты', cacheMisses: 'Промахи', cacheSize: 'Размер', cacheRate: 'Точность',
|
|
396
408
|
poolAll: 'без очереди', poolActive: 'активных', poolWait: 'ждут',
|
|
397
|
-
searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...',
|
|
409
|
+
searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...', searchRecent: 'Поиск по модели/провайдеру...',
|
|
410
|
+
recentOk: 'OK', recentErr: 'Ошибки',
|
|
398
411
|
filterAll: 'Все', filterUp: 'Up', filterLimit: 'Лимит', filterError: 'Ошибка', filterUnknown: 'Неизвестно',
|
|
399
412
|
thStatus: 'Статус', thProvider: 'Провайдер', thLatency: 'Задержка', thReliability: 'Стабильность',
|
|
400
413
|
thLimit: 'Лимит', thReason: 'Причина', dot: '—',
|
|
@@ -420,18 +433,19 @@ const HTML = `<!DOCTYPE html>
|
|
|
420
433
|
en: {
|
|
421
434
|
tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
|
|
422
435
|
tabContext: 'Context', tabSetup: 'Settings (keys)',
|
|
423
|
-
crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management',
|
|
436
|
+
crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management', topTitle: 'Control Panel',
|
|
424
437
|
kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
|
|
425
438
|
kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
|
|
426
439
|
cardSavings: 'Savings (vs paid APIs)', savings: 'Saved', tokens: 'tokens',
|
|
427
440
|
savingsBasis: 'estimate: input $3/M, output $15/M (representative)',
|
|
428
441
|
cardCache: 'Cache', cardTokens: 'Tokens', cardPool: 'Queue',
|
|
429
|
-
cardRpm: 'Requests per minute', cardRecent: 'Recent requests',
|
|
442
|
+
cardRpm: 'Requests per minute', cardRecent: 'Recent requests', cardSpark: 'Success today (hourly)',
|
|
430
443
|
cardCtx: 'Context (bottleneck)', cardTasks: 'Task categories (24h)',
|
|
431
444
|
loading: 'Loading...', noData: 'No data',
|
|
432
445
|
cacheHits: 'Hits', cacheMisses: 'Misses', cacheSize: 'Size', cacheRate: 'Rate',
|
|
433
446
|
poolAll: 'no queue', poolActive: 'active', poolWait: 'waiting',
|
|
434
|
-
searchProvider: 'Search provider...', searchModel: 'Search model...',
|
|
447
|
+
searchProvider: 'Search provider...', searchModel: 'Search model...', searchRecent: 'Search by model/provider...',
|
|
448
|
+
recentOk: 'OK', recentErr: 'Errors',
|
|
435
449
|
filterAll: 'All', filterUp: 'Up', filterLimit: 'Limit', filterError: 'Error', filterUnknown: 'Unknown',
|
|
436
450
|
thStatus: 'Status', thProvider: 'Provider', thLatency: 'Latency', thReliability: 'Reliability',
|
|
437
451
|
thLimit: 'Limit', thReason: 'Reason', dot: '—',
|
|
@@ -489,7 +503,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
489
503
|
|
|
490
504
|
// ── состояние клиента ──
|
|
491
505
|
var _health = {}, _limits = {}, _cache = null, _pool = {}, _statsData = null, _ctxSummary = null;
|
|
492
|
-
var _rpmData = [], _recent = [];
|
|
506
|
+
var _rpmData = [], _recent = [], _hourly = [];
|
|
493
507
|
var _models = [], _modelStats = null, _modelManager = null, _modelsFetchedAt = 0;
|
|
494
508
|
var _savings = null;
|
|
495
509
|
var _providerFilter = 'all', _providerQuery = '';
|
|
@@ -546,10 +560,12 @@ const HTML = `<!DOCTYPE html>
|
|
|
546
560
|
_pool = _statsData.pool || {};
|
|
547
561
|
_ctxSummary = _statsData.context_summary || null;
|
|
548
562
|
_savings = _statsData.savings || null;
|
|
563
|
+
_hourly = _statsData.hourly || [];
|
|
549
564
|
renderKpi();
|
|
550
565
|
renderAlerts();
|
|
551
566
|
renderOverviewCards();
|
|
552
567
|
renderRpm(_rpmData);
|
|
568
|
+
renderSparkline(_hourly);
|
|
553
569
|
renderRecent(_recent);
|
|
554
570
|
renderProvidersTable();
|
|
555
571
|
renderContext(_ctxSummary);
|
|
@@ -692,15 +708,70 @@ const HTML = `<!DOCTYPE html>
|
|
|
692
708
|
ctx.fillText('сейчас', w - 44, h - 3);
|
|
693
709
|
}
|
|
694
710
|
|
|
711
|
+
// ── Sparkline: успех/ошибки по часам (24ч) ──
|
|
712
|
+
function renderSparkline(hourly) {
|
|
713
|
+
var canvas = document.getElementById('sparkline');
|
|
714
|
+
if (!canvas) return;
|
|
715
|
+
var ctx = canvas.getContext('2d');
|
|
716
|
+
canvas.width = canvas.offsetWidth;
|
|
717
|
+
canvas.height = canvas.offsetHeight;
|
|
718
|
+
var w = canvas.width, h = canvas.height, padL = 8, padB = 18, padT = 8;
|
|
719
|
+
ctx.clearRect(0, 0, w, h);
|
|
720
|
+
if (!hourly || hourly.length === 0) {
|
|
721
|
+
ctx.fillStyle = '#5d6d7d'; ctx.font = '11px sans-serif';
|
|
722
|
+
ctx.fillText(t('noData'), padL + 4, h / 2);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
var data = hourly;
|
|
726
|
+
var maxVal = 1;
|
|
727
|
+
for (var i = 0; i < data.length; i++) if ((data[i].total || 0) > maxVal) maxVal = data[i].total;
|
|
728
|
+
var step = (w - padL - padB) / Math.max(data.length - 1, 1);
|
|
729
|
+
var barW = Math.max(2, Math.min(12, step - 3));
|
|
730
|
+
// колонки успеха (зелёные) + хвост ошибок (красный)
|
|
731
|
+
for (var i = 0; i < data.length; i++) {
|
|
732
|
+
var d = data[i];
|
|
733
|
+
var total = d.total || 0;
|
|
734
|
+
if (total === 0) continue;
|
|
735
|
+
var ok = d.ok || 0;
|
|
736
|
+
var fail = total - ok;
|
|
737
|
+
var x = padL + i * step;
|
|
738
|
+
var headH = (h - padT - padB);
|
|
739
|
+
var okH = (ok / maxVal) * headH;
|
|
740
|
+
var failH = (fail / maxVal) * headH;
|
|
741
|
+
var baseY = h - padB;
|
|
742
|
+
if (failH > 0) {
|
|
743
|
+
ctx.fillStyle = '#f87171';
|
|
744
|
+
ctx.fillRect(x, baseY - okH - failH, barW, failH);
|
|
745
|
+
}
|
|
746
|
+
ctx.fillStyle = '#4ade80';
|
|
747
|
+
ctx.fillRect(x, baseY - okH, barW, okH);
|
|
748
|
+
}
|
|
749
|
+
// подписи первого/последнего часа
|
|
750
|
+
ctx.fillStyle = '#5d6d7d'; ctx.font = '10px monospace';
|
|
751
|
+
ctx.fillText((data[0].hour || '').slice(8, 13), padL, h - 4);
|
|
752
|
+
var lastLbl = (data[data.length - 1].hour || '').slice(8, 13);
|
|
753
|
+
ctx.fillText(lastLbl, w - padB - 30, h - 4);
|
|
754
|
+
}
|
|
755
|
+
|
|
695
756
|
// ── недавние запросы ──
|
|
696
757
|
function renderRecent(recent) {
|
|
697
758
|
var table = document.getElementById('recentTable');
|
|
759
|
+
var q = (document.getElementById('recentSearch') ? (document.getElementById('recentSearch').value || '') : '').toLowerCase();
|
|
760
|
+
var stF = document.getElementById('recentStatusFilter') ? document.getElementById('recentStatusFilter').value : 'all';
|
|
761
|
+
var rows = recent.filter(function (r) {
|
|
762
|
+
if (stF === 'ok' && r.status !== 200) return false;
|
|
763
|
+
if (stF === 'err' && (r.status === 200 || r.status === undefined)) return false;
|
|
764
|
+
if (q && ((r.model || '') + ' ' + (r.provider || '')).toLowerCase().indexOf(q) < 0) return false;
|
|
765
|
+
return true;
|
|
766
|
+
});
|
|
698
767
|
var html = '<tr><th>' + t('thRecentTime') + '</th><th>' + t('thRecentModel') + '</th><th>' + t('thRecentProvider') + '</th><th>' + t('thRecentStatus') + '</th><th>' + t('thRecentLatency') + '</th></tr>';
|
|
699
|
-
|
|
700
|
-
var sc = r.status === 200 ? '
|
|
768
|
+
rows.forEach(function (r) {
|
|
769
|
+
var sc = r.status === 200 ? '#4ade80' : '#f87171';
|
|
701
770
|
html += '<tr><td>' + new Date(r.timestamp).toLocaleTimeString() + '</td><td>' + esc(r.model || '-') + '</td><td>' + esc(r.provider || '-') + '</td><td style="color:' + sc + '">' + (r.status || '-') + '</td><td>' + (r.latency || 0) + 'ms</td></tr>';
|
|
702
771
|
});
|
|
703
772
|
table.innerHTML = html;
|
|
773
|
+
var cnt = document.getElementById('recentCount');
|
|
774
|
+
if (cnt) cnt.textContent = rows.length + ' / ' + recent.length;
|
|
704
775
|
}
|
|
705
776
|
|
|
706
777
|
// ── ПРОВАЙДЕРЫ: фильтры/сортировка/таблица ──
|