freegate 0.6.15 → 0.6.17
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 +72 -7
- package/README.ru.md +93 -7
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +32 -5
- package/config.example.json +13 -0
- package/lib/cache.js +54 -5
- package/lib/clean.js +7 -2
- package/lib/compactor.js +220 -0
- package/lib/contextstats.js +224 -0
- package/lib/dashboard.js +791 -127
- package/lib/economics.js +43 -0
- package/lib/health.js +56 -9
- package/lib/logger.js +1 -1
- package/lib/memory-store.js +68 -0
- package/lib/memory.js +167 -0
- package/lib/methodology.js +70 -0
- package/lib/modeldb.js +125 -0
- package/lib/modelmanager.js +335 -0
- package/lib/modelscan.js +190 -0
- package/lib/normalize.js +14 -4
- package/lib/providers.js +68 -5
- package/lib/routing.js +14 -2
- package/lib/semcache.js +31 -0
- package/lib/setup.js +309 -0
- package/lib/taskclassify.js +59 -0
- package/package.json +1 -1
- package/providers.json +113 -137
- package/server.js +750 -98
package/lib/providers.js
CHANGED
|
@@ -3,6 +3,7 @@ const https = require('https');
|
|
|
3
3
|
const http = require('http');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { StringDecoder } = require('string_decoder');
|
|
6
7
|
const logger = require('./logger');
|
|
7
8
|
|
|
8
9
|
// Load .env if present — prefer cwd (user's project) then package dir
|
|
@@ -58,6 +59,14 @@ function loadConfig() {
|
|
|
58
59
|
return cfg;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// Keep-alive agents: reuse TCP+TLS connections across requests instead of
|
|
63
|
+
// paying a new handshake (30-200ms) per call. Sockets are reused within the
|
|
64
|
+
// free agent socket limit (256 default), old idle sockets are freed on response.
|
|
65
|
+
const keepAliveAgents = {
|
|
66
|
+
'https:': new https.Agent({ keepAlive: true, keepAliveMsecs: 20000, maxSockets: 64 }),
|
|
67
|
+
'http:': new http.Agent({ keepAlive: true, keepAliveMsecs: 20000, maxSockets: 64 }),
|
|
68
|
+
};
|
|
69
|
+
|
|
61
70
|
const config = loadConfig();
|
|
62
71
|
const PROVIDERS = config.providers;
|
|
63
72
|
|
|
@@ -75,6 +84,44 @@ for (const [key, provider] of Object.entries(PROVIDERS)) {
|
|
|
75
84
|
if (envKey) provider.apiKey = envKey;
|
|
76
85
|
}
|
|
77
86
|
|
|
87
|
+
// Reload providers.json + config.json WITHOUT restarting the server.
|
|
88
|
+
// Mutates the existing PROVIDERS object in place (same reference that other
|
|
89
|
+
// modules hold), adding/updating live providers and removing disabled ones.
|
|
90
|
+
// The env-key resolution is re-run so providers enabled/disabled via config.json
|
|
91
|
+
// pick up their apiKey again. Returns a summary of what changed.
|
|
92
|
+
function reloadProviders() {
|
|
93
|
+
const prevKeys = new Set(Object.keys(PROVIDERS));
|
|
94
|
+
const cfg = loadConfig();
|
|
95
|
+
const next = cfg.providers;
|
|
96
|
+
|
|
97
|
+
// Remove providers that no longer exist or were disabled in config.
|
|
98
|
+
for (const key of [...prevKeys]) {
|
|
99
|
+
if (!next[key]) {
|
|
100
|
+
delete PROVIDERS[key];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Add or update providers (preserving the same object reference).
|
|
104
|
+
for (const [key, provider] of Object.entries(next)) {
|
|
105
|
+
const isNew = !PROVIDERS[key];
|
|
106
|
+
const merged = { ...(PROVIDERS[key] || {}), ...provider, key };
|
|
107
|
+
// Re-resolve apiKey from env (a provider may have been re-enabled).
|
|
108
|
+
const explicitVar = merged.envVar;
|
|
109
|
+
const derivedVar = `PROVIDER_${key.toUpperCase().replace(/-/g, '_')}_APIKEY`;
|
|
110
|
+
const prefix = key.split('-')[0].toUpperCase();
|
|
111
|
+
const prefixMap = { OR: 'OPENROUTER', NIM: 'NIM', GROQ: 'GROQ', MISTRAL: 'MISTRAL', GEMINI: 'GEMINI', ZAI: 'ZAI', HF: 'HF' };
|
|
112
|
+
const resolvedPrefix = prefixMap[prefix] || prefix;
|
|
113
|
+
const envKey = process.env[explicitVar] || process.env[derivedVar] || process.env[`PROVIDER_${resolvedPrefix}_APIKEY`];
|
|
114
|
+
if (envKey) merged.apiKey = envKey; else delete merged.apiKey;
|
|
115
|
+
merged.key = key;
|
|
116
|
+
PROVIDERS[key] = merged;
|
|
117
|
+
if (isNew) logger.info('Hot-reload: added provider', { key });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const removedCount = prevKeys.size - Object.keys(PROVIDERS).size;
|
|
121
|
+
logger.info('Hot-reload: providers updated', { total: Object.keys(PROVIDERS).length, removed: removedCount });
|
|
122
|
+
return { total: Object.keys(PROVIDERS).length, removed: removedCount };
|
|
123
|
+
}
|
|
124
|
+
|
|
78
125
|
const MODEL_MAP = {
|
|
79
126
|
'glm-4.7-flash': 'zai', 'glm-4.5': 'zai', 'glm-4.5-air': 'zai',
|
|
80
127
|
'glm-4.7': 'zai', 'glm-5': 'zai', 'glm-5-turbo': 'zai',
|
|
@@ -85,9 +132,9 @@ const MODEL_MAP = {
|
|
|
85
132
|
'gemini-3.6-flash': 'gemini-flash',
|
|
86
133
|
'codestral-latest': 'mistral-codestral', 'mistral-small-latest': 'mistral-small',
|
|
87
134
|
'mistral-medium-latest': 'mistral-small', 'mistral-large-latest': 'mistral-small',
|
|
88
|
-
'tier-splus': 'or-
|
|
135
|
+
'tier-splus': 'or-minimax-m3-free', 'tier-s': 'mistral-codestral',
|
|
89
136
|
'tier-a': 'mistral-small', 'tier-b': 'groq-qwen',
|
|
90
|
-
'tier-xl': 'or-
|
|
137
|
+
'tier-xl': 'or-dots-3', 'tier-l': 'or-minimax-m3-free',
|
|
91
138
|
'openrouter-hermes': 'openrouter-hermes',
|
|
92
139
|
'openrouter-mistral': 'openrouter-mistral',
|
|
93
140
|
'nvidia/llama-3.1-nemotron-ultra-253b-v1': 'nim-nemotron',
|
|
@@ -133,6 +180,7 @@ function callProvider(provider, body, timeout = 30000, retries = 1) {
|
|
|
133
180
|
path: url.pathname,
|
|
134
181
|
method: 'POST',
|
|
135
182
|
timeout,
|
|
183
|
+
agent: keepAliveAgents[url.protocol] || undefined,
|
|
136
184
|
headers: {
|
|
137
185
|
'Content-Type': 'application/json',
|
|
138
186
|
...(provider.apiKey ? { 'Authorization': `Bearer ${provider.apiKey}` } : {}),
|
|
@@ -147,18 +195,31 @@ function callProvider(provider, body, timeout = 30000, retries = 1) {
|
|
|
147
195
|
|
|
148
196
|
if (res.statusCode !== 200) {
|
|
149
197
|
let data = '';
|
|
150
|
-
|
|
198
|
+
const dec = new StringDecoder('utf8');
|
|
199
|
+
res.on('data', chunk => data += dec.write(chunk));
|
|
151
200
|
res.on('end', () => {
|
|
201
|
+
data += dec.end();
|
|
152
202
|
let errorMsg;
|
|
203
|
+
let providerSide = false;
|
|
153
204
|
try {
|
|
154
205
|
const errData = JSON.parse(data);
|
|
155
206
|
errorMsg = errData.error?.message || errData.message || data.slice(0, 200);
|
|
207
|
+
// OpenRouter 404s split into two very different cases:
|
|
208
|
+
// «does not exist» / «not found» → model truly gone → disable forever.
|
|
209
|
+
// «Provider returned error» (with metadata.provider_name) → the upstream
|
|
210
|
+
// (Nvidia, etc.) is temporarily failing/quota'd → must NOT permanently
|
|
211
|
+
// disable, it may recover after a health-check.
|
|
212
|
+
if (/provider.?side|provider returned error/i.test(errorMsg) ||
|
|
213
|
+
errData.error?.metadata?.provider_name) {
|
|
214
|
+
providerSide = true;
|
|
215
|
+
}
|
|
156
216
|
} catch {
|
|
157
217
|
errorMsg = data.slice(0, 200);
|
|
158
218
|
}
|
|
159
219
|
const err = new Error(`${provider.key}: ${res.statusCode} — ${errorMsg}`);
|
|
160
220
|
err.statusCode = res.statusCode;
|
|
161
221
|
err.providerKey = provider.key;
|
|
222
|
+
err.providerSide = providerSide;
|
|
162
223
|
handleRetry(err, remaining);
|
|
163
224
|
});
|
|
164
225
|
return;
|
|
@@ -170,8 +231,10 @@ function callProvider(provider, body, timeout = 30000, retries = 1) {
|
|
|
170
231
|
}
|
|
171
232
|
|
|
172
233
|
let data = '';
|
|
173
|
-
|
|
234
|
+
const dec = new StringDecoder('utf8');
|
|
235
|
+
res.on('data', chunk => data += dec.write(chunk));
|
|
174
236
|
res.on('end', () => {
|
|
237
|
+
data += dec.end();
|
|
175
238
|
try {
|
|
176
239
|
const parsed = JSON.parse(data);
|
|
177
240
|
resolve({ data: parsed, statusCode: 200, latency, usage: parsed.usage });
|
|
@@ -212,4 +275,4 @@ function callProvider(provider, body, timeout = 30000, retries = 1) {
|
|
|
212
275
|
});
|
|
213
276
|
}
|
|
214
277
|
|
|
215
|
-
module.exports = { PROVIDERS, MODEL_MAP, callProvider };
|
|
278
|
+
module.exports = { PROVIDERS, MODEL_MAP, callProvider, reloadProviders };
|
package/lib/routing.js
CHANGED
|
@@ -44,7 +44,7 @@ function classifyComplexity(messages) {
|
|
|
44
44
|
|
|
45
45
|
// Поднятие тира при высокой сложности. Простые остаются на месте.
|
|
46
46
|
const UPGRADE_MAP = {
|
|
47
|
-
'tier-s': 'tier-splus', // лёгкий → мощный (
|
|
47
|
+
'tier-s': 'tier-splus', // лёгкий → мощный (nemotron-35 reasoning)
|
|
48
48
|
'tier-a': 'tier-s', // лёгкий → быстрый
|
|
49
49
|
'tier-b': 'tier-s', // лёгкий → быстрый
|
|
50
50
|
};
|
|
@@ -77,4 +77,16 @@ function classifyVisionComplexity(text) {
|
|
|
77
77
|
return Math.min(score, 1);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// Window-aware upgrade: если запрос не влезает в окно целевой модели
|
|
81
|
+
// (est > win × factor), роутим на провайдера с подходящим окном.
|
|
82
|
+
// factor 1.5: estimateTokens завышает real в ~1.7× (кириллица) — при
|
|
83
|
+
// est > 1.5×win реальные токены почти гарантированно поверх окна.
|
|
84
|
+
const WINDOW_FIT_FACTOR = 1.5;
|
|
85
|
+
|
|
86
|
+
function needsWindowUpgrade(targetWin, requestTokens, factor = WINDOW_FIT_FACTOR) {
|
|
87
|
+
if (!targetWin || targetWin <= 0) return false;
|
|
88
|
+
if (!requestTokens || requestTokens <= 0) return false;
|
|
89
|
+
return requestTokens > targetWin * factor;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { classifyComplexity, maybeUpgradeTier, COMPLEX_THRESHOLD, classifyVisionComplexity, needsWindowUpgrade, WINDOW_FIT_FACTOR };
|
package/lib/semcache.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// lib/semcache.js
|
|
2
|
+
// Символьные триграммы + Dice coefficient — zero-dep мера близости короткого
|
|
3
|
+
// текста для семантического кэша. Триграммы инвариантны к регистру и почти
|
|
4
|
+
// инвариантны к мелким перефразировкам («исправь ошибку» vs «исправь ошибку
|
|
5
|
+
// пожалуйста»), что и ловит повторные агентские промпты с другой формулировкой.
|
|
6
|
+
|
|
7
|
+
function trigrams(text) {
|
|
8
|
+
if (!text || typeof text !== 'string') return [];
|
|
9
|
+
const t = text.toLowerCase();
|
|
10
|
+
if (t.length < 3) return [];
|
|
11
|
+
const out = [];
|
|
12
|
+
for (let i = 0; i <= t.length - 3; i++) out.push(t.slice(i, i + 3));
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dice(aSet, bSet) {
|
|
17
|
+
const ia = aSet.size;
|
|
18
|
+
const ib = bSet.size;
|
|
19
|
+
if (ia === 0 && ib === 0) return 1;
|
|
20
|
+
if (ia === 0 || ib === 0) return 0;
|
|
21
|
+
let inter = 0;
|
|
22
|
+
const [small, large] = ia <= ib ? [aSet, bSet] : [bSet, aSet];
|
|
23
|
+
for (const g of small) if (large.has(g)) inter++;
|
|
24
|
+
return (2 * inter) / (ia + ib);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function similarityScore(textA, textB) {
|
|
28
|
+
return dice(new Set(trigrams(textA)), new Set(trigrams(textB)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { trigrams, dice, similarityScore };
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// lib/setup.js
|
|
2
|
+
// Handles .env key reading, writing, and validation for the setup dashboard.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// --- Setup instructions for each key group ---
|
|
8
|
+
const KEY_GROUPS = {
|
|
9
|
+
PROVIDER_GROQ_APIKEY: {
|
|
10
|
+
name: 'Groq',
|
|
11
|
+
name_en: 'Groq',
|
|
12
|
+
url: 'https://console.groq.com/keys',
|
|
13
|
+
description: 'Быстрый инференс: GPT-OSS-120B, Qwen 3.6, Allam-2. Бесплатно, без кредитки.',
|
|
14
|
+
description_en: 'Fast inference: GPT-OSS-120B, Qwen 3.6, Allam-2. Free, no credit card.',
|
|
15
|
+
steps: [
|
|
16
|
+
'Зайди на console.groq.com и войди через Google/GitHub',
|
|
17
|
+
'Нажми "Create API Key"',
|
|
18
|
+
'Скопируй ключ (начинается на gsk_)',
|
|
19
|
+
'Вставь в поле ниже',
|
|
20
|
+
],
|
|
21
|
+
steps_en: [
|
|
22
|
+
'Go to console.groq.com and sign in with Google/GitHub',
|
|
23
|
+
'Click "Create API Key"',
|
|
24
|
+
'Copy the key (starts with gsk_)',
|
|
25
|
+
'Paste it into the field below',
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
PROVIDER_MISTRAL_APIKEY: {
|
|
29
|
+
name: 'Mistral',
|
|
30
|
+
name_en: 'Mistral',
|
|
31
|
+
url: 'https://console.mistral.ai/keys',
|
|
32
|
+
description: 'Codestral (кодинг) + Mistral Small. Бесплатно, без кредитки.',
|
|
33
|
+
description_en: 'Codestral (coding) + Mistral Small. Free, no credit card.',
|
|
34
|
+
steps: [
|
|
35
|
+
'Зайди на console.mistral.ai и войди через Google/GitHub',
|
|
36
|
+
'Перейди в раздел API Keys',
|
|
37
|
+
'Нажми "Create new API key"',
|
|
38
|
+
'Скопируй ключ',
|
|
39
|
+
'Вставь в поле ниже',
|
|
40
|
+
],
|
|
41
|
+
steps_en: [
|
|
42
|
+
'Go to console.mistral.ai and sign in with Google/GitHub',
|
|
43
|
+
'Go to the API Keys section',
|
|
44
|
+
'Click "Create new API key"',
|
|
45
|
+
'Copy the key',
|
|
46
|
+
'Paste it into the field below',
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
PROVIDER_GEMINI_APIKEY: {
|
|
50
|
+
name: 'Google Gemini',
|
|
51
|
+
name_en: 'Google Gemini',
|
|
52
|
+
url: 'https://aistudio.google.com/apikey',
|
|
53
|
+
description: 'Gemini 3.6 Flash + Vision. Бесплатно, 1500 req/день.',
|
|
54
|
+
description_en: 'Gemini 3.6 Flash + Vision. Free, 1500 req/day.',
|
|
55
|
+
steps: [
|
|
56
|
+
'Зайди на aistudio.google.com',
|
|
57
|
+
'Нажми "Get API key" (левое меню)',
|
|
58
|
+
'Создай ключ в любом Google-проекте',
|
|
59
|
+
'Скопируй ключ (AIza...)',
|
|
60
|
+
'Вставь в поле ниже',
|
|
61
|
+
],
|
|
62
|
+
steps_en: [
|
|
63
|
+
'Go to aistudio.google.com',
|
|
64
|
+
'Click "Get API key" (left menu)',
|
|
65
|
+
'Create a key in any Google project',
|
|
66
|
+
'Copy the key (AIza...)',
|
|
67
|
+
'Paste it into the field below',
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
PROVIDER_NIM_APIKEY: {
|
|
71
|
+
name: 'NVIDIA NIM',
|
|
72
|
+
name_en: 'NVIDIA NIM',
|
|
73
|
+
url: 'https://build.nvidia.com/settings/api-key',
|
|
74
|
+
description: 'DeepSeek V4 Flash, Llama 3.1, Vision. 40 req/день бесплатно.',
|
|
75
|
+
description_en: 'DeepSeek V4 Flash, Llama 3.1, Vision. 40 req/day free.',
|
|
76
|
+
steps: [
|
|
77
|
+
'Зайди на build.nvidia.com и войди через Google/GitHub',
|
|
78
|
+
'Перейди в Settings → API Keys',
|
|
79
|
+
'Нажми "Generate Key"',
|
|
80
|
+
'Скопируй ключ (nvapi-...)',
|
|
81
|
+
'Вставь в поле ниже',
|
|
82
|
+
],
|
|
83
|
+
steps_en: [
|
|
84
|
+
'Go to build.nvidia.com and sign in with Google/GitHub',
|
|
85
|
+
'Go to Settings → API Keys',
|
|
86
|
+
'Click "Generate Key"',
|
|
87
|
+
'Copy the key (nvapi-...)',
|
|
88
|
+
'Paste it into the field below',
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
PROVIDER_ZAI_APIKEY: {
|
|
92
|
+
name: 'ZAI (BigModel)',
|
|
93
|
+
name_en: 'ZAI (BigModel)',
|
|
94
|
+
url: 'https://open.bigmodel.cn/usercenter/apikeys',
|
|
95
|
+
description: 'GLM-4.7 Flash. Бесплатно, китайский провайдер.',
|
|
96
|
+
description_en: 'GLM-4.7 Flash. Free, Chinese provider.',
|
|
97
|
+
steps: [
|
|
98
|
+
'Зайди на open.bigmodel.cn и зарегистрируйся',
|
|
99
|
+
'Перейди в API Keys',
|
|
100
|
+
'Нажми "Создать ключ"',
|
|
101
|
+
'Скопируй ключ',
|
|
102
|
+
'Вставь в поле ниже',
|
|
103
|
+
],
|
|
104
|
+
steps_en: [
|
|
105
|
+
'Go to open.bigmodel.cn and register',
|
|
106
|
+
'Go to API Keys',
|
|
107
|
+
'Click "Create key"',
|
|
108
|
+
'Copy the key',
|
|
109
|
+
'Paste it into the field below',
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
PROVIDER_OPENROUTER_APIKEY: {
|
|
113
|
+
name: 'OpenRouter',
|
|
114
|
+
name_en: 'OpenRouter',
|
|
115
|
+
url: 'https://openrouter.ai/keys',
|
|
116
|
+
description: '15+ бесплатных моделей: Nemotron, Ox Alpha, Gemma, MiniMax. Универсальный провайдер.',
|
|
117
|
+
description_en: '15+ free models: Nemotron, Ox Alpha, Gemma, MiniMax. Universal provider.',
|
|
118
|
+
steps: [
|
|
119
|
+
'Зайди на openrouter.ai и войди через Google/GitHub',
|
|
120
|
+
'Перейди в раздел Keys (левое меню)',
|
|
121
|
+
'Нажми "Create Key"',
|
|
122
|
+
'Скопируй ключ (sk-or-...)',
|
|
123
|
+
'Вставь в поле ниже',
|
|
124
|
+
],
|
|
125
|
+
steps_en: [
|
|
126
|
+
'Go to openrouter.ai and sign in with Google/GitHub',
|
|
127
|
+
'Go to the Keys section (left menu)',
|
|
128
|
+
'Click "Create Key"',
|
|
129
|
+
'Copy the key (sk-or-...)',
|
|
130
|
+
'Paste it into the field below',
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
PROVIDER_CEREBRAS_APIKEY: {
|
|
134
|
+
name: 'Cerebras',
|
|
135
|
+
name_en: 'Cerebras',
|
|
136
|
+
url: 'https://cloud.cerebras.ai/overview',
|
|
137
|
+
description: 'GPT-OSS-120B + Gemma-4-31B. Очень быстрый инференс, бесплатно.',
|
|
138
|
+
description_en: 'GPT-OSS-120B + Gemma-4-31B. Very fast inference, free.',
|
|
139
|
+
steps: [
|
|
140
|
+
'Зайди на cloud.cerebras.ai и войди через Google/GitHub',
|
|
141
|
+
'Перейди в API Keys (левое меню)',
|
|
142
|
+
'Нажми "Create API Key"',
|
|
143
|
+
'Скопируй ключ',
|
|
144
|
+
'Вставь в поле ниже',
|
|
145
|
+
],
|
|
146
|
+
steps_en: [
|
|
147
|
+
'Go to cloud.cerebras.ai and sign in with Google/GitHub',
|
|
148
|
+
'Go to API Keys (left menu)',
|
|
149
|
+
'Click "Create API Key"',
|
|
150
|
+
'Copy the key',
|
|
151
|
+
'Paste it into the field below',
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
PROVIDER_DEEPSEEK_APIKEY: {
|
|
155
|
+
name: 'DeepSeek',
|
|
156
|
+
name_en: 'DeepSeek',
|
|
157
|
+
url: 'https://platform.deepseek.com/api_keys',
|
|
158
|
+
description: 'DeepSeek V4 Flash + Vision. Бесплатно (新开户赠送额度).',
|
|
159
|
+
description_en: 'DeepSeek V4 Flash + Vision. Free (new-account credit).',
|
|
160
|
+
steps: [
|
|
161
|
+
'Зайди на platform.deepseek.com и зарегистрируйся',
|
|
162
|
+
'Перейди в раздел API Keys',
|
|
163
|
+
'Нажми "Создать ключ"',
|
|
164
|
+
'Скопируй ключ',
|
|
165
|
+
'Вставь в поле ниже',
|
|
166
|
+
],
|
|
167
|
+
steps_en: [
|
|
168
|
+
'Go to platform.deepseek.com and register',
|
|
169
|
+
'Go to the API Keys section',
|
|
170
|
+
'Click "Create key"',
|
|
171
|
+
'Copy the key',
|
|
172
|
+
'Paste it into the field below',
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
PROVIDER_HF_APIKEY: {
|
|
176
|
+
name: 'HuggingFace',
|
|
177
|
+
name_en: 'HuggingFace',
|
|
178
|
+
url: 'https://huggingface.co/settings/tokens',
|
|
179
|
+
description: '7+ моделей: Qwen3.8, DeepSeek V4, Gemma-4, GLM-5.2. Бесплатно, 200 req/день.',
|
|
180
|
+
description_en: '7+ models: Qwen3.8, DeepSeek V4, Gemma-4, GLM-5.2. Free, 200 req/day.',
|
|
181
|
+
steps: [
|
|
182
|
+
'Зайди на huggingface.co и зарегистрируйся',
|
|
183
|
+
'Перейди в Settings → Access Tokens',
|
|
184
|
+
'Нажми "New token" → тип "Read"',
|
|
185
|
+
'Скопируй ключ (hf_...)',
|
|
186
|
+
'Вставь в поле ниже',
|
|
187
|
+
],
|
|
188
|
+
steps_en: [
|
|
189
|
+
'Go to huggingface.co and register',
|
|
190
|
+
'Go to Settings → Access Tokens',
|
|
191
|
+
'Click "New token" → type "Read"',
|
|
192
|
+
'Copy the key (hf_...)',
|
|
193
|
+
'Paste it into the field below',
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// --- .env file path resolution (mirrors lib/providers.js logic) ---
|
|
199
|
+
function getEnvPath() {
|
|
200
|
+
const candidates = [
|
|
201
|
+
path.join(process.cwd(), '.env'),
|
|
202
|
+
path.join(__dirname, '..', '.env'),
|
|
203
|
+
];
|
|
204
|
+
return candidates.find((p) => fs.existsSync(p)) || candidates[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Read current keys from .env (returns { ENV_VAR: 'value' }) ---
|
|
208
|
+
function readKeys() {
|
|
209
|
+
const envPath = module.exports.getEnvPath();
|
|
210
|
+
const keys = {};
|
|
211
|
+
// Initialize all known keys as empty
|
|
212
|
+
for (const envVar of Object.keys(KEY_GROUPS)) {
|
|
213
|
+
keys[envVar] = '';
|
|
214
|
+
}
|
|
215
|
+
if (!fs.existsSync(envPath)) return { keys, envPath };
|
|
216
|
+
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
const trimmed = line.trim();
|
|
219
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
220
|
+
const eqIdx = trimmed.indexOf('=');
|
|
221
|
+
if (eqIdx < 0) continue;
|
|
222
|
+
const k = trimmed.slice(0, eqIdx).trim();
|
|
223
|
+
const v = trimmed.slice(eqIdx + 1).trim();
|
|
224
|
+
if (keys.hasOwnProperty(k)) {
|
|
225
|
+
keys[k] = v;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { keys, envPath };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Save keys to .env (upsert, preserves other vars) ---
|
|
232
|
+
function saveKeys(newKeys) {
|
|
233
|
+
const envPath = module.exports.getEnvPath();
|
|
234
|
+
let lines = [];
|
|
235
|
+
if (fs.existsSync(envPath)) {
|
|
236
|
+
lines = fs.readFileSync(envPath, 'utf8').split('\n');
|
|
237
|
+
}
|
|
238
|
+
const existingKeys = new Set();
|
|
239
|
+
for (let i = 0; i < lines.length; i++) {
|
|
240
|
+
const trimmed = lines[i].trim();
|
|
241
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
242
|
+
const eqIdx = trimmed.indexOf('=');
|
|
243
|
+
if (eqIdx < 0) continue;
|
|
244
|
+
const k = trimmed.slice(0, eqIdx).trim();
|
|
245
|
+
if (newKeys.hasOwnProperty(k)) {
|
|
246
|
+
// Пустые значения не затирают существующий ключ — сохраняем старый.
|
|
247
|
+
if (newKeys[k]) {
|
|
248
|
+
lines[i] = `${k}=${newKeys[k]}`;
|
|
249
|
+
}
|
|
250
|
+
existingKeys.add(k);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Append new keys that weren't in the file
|
|
254
|
+
for (const [k, v] of Object.entries(newKeys)) {
|
|
255
|
+
if (!existingKeys.has(k) && v) {
|
|
256
|
+
lines.push(`${k}=${v}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
fs.writeFileSync(envPath, lines.join('\n') + '\n', 'utf8');
|
|
260
|
+
return { envPath, saved: Object.keys(newKeys).filter((k) => newKeys[k]) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Get the real (unmasked) key from .env by envVar ---
|
|
264
|
+
function getStoredKey(envVar) {
|
|
265
|
+
const { keys } = readKeys();
|
|
266
|
+
return keys[envVar] || '';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Validate a key by calling the provider's /models endpoint ---
|
|
270
|
+
async function validateKey(envVar, apiKey) {
|
|
271
|
+
const group = KEY_GROUPS[envVar];
|
|
272
|
+
if (!group) return { valid: false, error: 'Unknown key group' };
|
|
273
|
+
|
|
274
|
+
// Map env var to a test endpoint
|
|
275
|
+
const testEndpoints = {
|
|
276
|
+
PROVIDER_GROQ_APIKEY: 'https://api.groq.com/openai/v1/models',
|
|
277
|
+
PROVIDER_MISTRAL_APIKEY: 'https://api.mistral.ai/v1/models',
|
|
278
|
+
PROVIDER_GEMINI_APIKEY: 'https://generativelanguage.googleapis.com/v1beta/models',
|
|
279
|
+
PROVIDER_NIM_APIKEY: 'https://integrate.api.nvidia.com/v1/models',
|
|
280
|
+
PROVIDER_ZAI_APIKEY: 'https://open.bigmodel.cn/api/paas/v4/models',
|
|
281
|
+
PROVIDER_OPENROUTER_APIKEY: 'https://openrouter.ai/api/v1/models',
|
|
282
|
+
PROVIDER_CEREBRAS_APIKEY: 'https://api.cerebras.ai/v1/models',
|
|
283
|
+
PROVIDER_DEEPSEEK_APIKEY: 'https://api.deepseek.com/models',
|
|
284
|
+
PROVIDER_HF_APIKEY: 'https://router.huggingface.co/v1/models',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const url = testEndpoints[envVar];
|
|
288
|
+
if (!url) return { valid: false, error: 'No test endpoint' };
|
|
289
|
+
|
|
290
|
+
// Gemini uses ?key= query param instead of Bearer token
|
|
291
|
+
const isGemini = envVar === 'PROVIDER_GEMINI_APIKEY';
|
|
292
|
+
const fetchUrl = isGemini ? url + '?key=' + encodeURIComponent(apiKey) : url;
|
|
293
|
+
const headers = isGemini ? {} : { 'Authorization': `Bearer ${apiKey}` };
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const res = await fetch(fetchUrl, {
|
|
297
|
+
method: 'GET',
|
|
298
|
+
headers,
|
|
299
|
+
signal: AbortSignal.timeout(10000),
|
|
300
|
+
});
|
|
301
|
+
if (res.ok) return { valid: true };
|
|
302
|
+
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Неверный ключ (HTTP ' + res.status + ')' };
|
|
303
|
+
return { valid: false, error: 'HTTP ' + res.status };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
return { valid: false, error: err.message.slice(0, 100) };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = { KEY_GROUPS, readKeys, saveKeys, validateKey, getEnvPath, getStoredKey };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Классификатор типа задачи для выбора методологии и приоритетной категории.
|
|
2
|
+
// Без LLM — чистые эвристики по последнему user-сообщению.
|
|
3
|
+
// Категории: coding | reasoning | search | chat.
|
|
4
|
+
|
|
5
|
+
const { looksLikeCode } = require('./normalize');
|
|
6
|
+
|
|
7
|
+
const CODING_HINTS = [
|
|
8
|
+
'напиши', 'исправь', 'пофикси', 'функци', 'отлади', 'пиши', 'верстк',
|
|
9
|
+
'класс', 'импорт', 'экспорт', 'компонент', 'обработку ошибок',
|
|
10
|
+
'refactor', 'debug', 'bug', 'test', 'fix', 'npm', 'git ', 'css', 'html',
|
|
11
|
+
'поправь', 'почини', 'отрефактори', 'рефактори', 'оптимизируй', 'оптимизир',
|
|
12
|
+
'чини', 'файл', 'строку', 'код', 'скрипт', 'переменн', 'функцию',
|
|
13
|
+
];
|
|
14
|
+
const REASONING_HINTS = [
|
|
15
|
+
'посчитай', 'сколько будет', 'почему', 'рассчитай', 'вероятност',
|
|
16
|
+
'логик', 'math', 'формул', 'процен', 'объясни причины', 'вычисл',
|
|
17
|
+
'знач', 'средн', 'сумм', 'объясни поче', 'рассуждай',
|
|
18
|
+
'сравни', 'проанализируй', 'анализируй', 'сравнение', 'анализ',
|
|
19
|
+
'докажи', 'спроектируй', 'оцени',
|
|
20
|
+
];
|
|
21
|
+
const SEARCH_HINTS = [
|
|
22
|
+
'что такое', 'найди', 'где находится', 'как называется', 'что означает',
|
|
23
|
+
'объясни термин', 'find', 'search', 'lookup', 'что за', 'кто такой',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function lastUserText(messages) {
|
|
27
|
+
if (!Array.isArray(messages)) return '';
|
|
28
|
+
const users = messages.filter(m => m && m.role === 'user');
|
|
29
|
+
const last = users[users.length - 1];
|
|
30
|
+
if (!last) return '';
|
|
31
|
+
if (typeof last.content === 'string') return last.content;
|
|
32
|
+
if (Array.isArray(last.content)) {
|
|
33
|
+
return last.content.filter(c => c && c.type === 'text').map(c => c.text || '').join(' ');
|
|
34
|
+
}
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function classify(messages) {
|
|
39
|
+
const text = lastUserText(messages).trim();
|
|
40
|
+
if (!text) return 'chat';
|
|
41
|
+
const lower = text.toLowerCase();
|
|
42
|
+
|
|
43
|
+
// Код — сильный сигнал: читаем/пишем код, чиним, рефакторим.
|
|
44
|
+
if (looksLikeCode(messages)) return 'coding';
|
|
45
|
+
// Идентификатор-вызов (fetchData(), sort()) или сигнатура функции — это код.
|
|
46
|
+
if (/[a-zA-Z_$][\w$]*\s*\(/.test(text) && /=>|\{|;|return|\.\w+\(/.test(text)) return 'coding';
|
|
47
|
+
if (CODING_HINTS.some(h => lower.includes(h))) return 'coding';
|
|
48
|
+
|
|
49
|
+
// Рассуждения — числа, подсчёт, объяснение причин.
|
|
50
|
+
if (/\d/.test(text) && /[+\-*/%]|сколько|посчитай|рассчитай/.test(lower)) return 'reasoning';
|
|
51
|
+
if (REASONING_HINTS.some(h => lower.includes(h))) return 'reasoning';
|
|
52
|
+
|
|
53
|
+
// Поиск — спрашивают, что это такое, где найти.
|
|
54
|
+
if (SEARCH_HINTS.some(h => lower.includes(h))) return 'search';
|
|
55
|
+
|
|
56
|
+
return 'chat';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { classify, lastUserText };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freegate",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.17",
|
|
4
4
|
"description": "Free multi-provider LLM gateway with automatic failover. One OpenAI-compatible endpoint routes to 25 free models (Groq, Mistral, Gemini, NIM, OpenRouter, ZAI, Cerebras, DeepSeek). Never pay for LLMs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|