freegate 0.6.15 → 0.6.16
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 +782 -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 +721 -93
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// lib/modelmanager.js — оркестратор самообновляющейся базы моделей.
|
|
2
|
+
// Цикл: seed каталога → проверка существующих → перепроверка мёртвых (старше N
|
|
3
|
+
// дней) → скан источников → параллельный тест новых → добавление рабочих →
|
|
4
|
+
// скор → write-back priority (только auto-managed ключи) → hot-reload.
|
|
5
|
+
// Single-flight: циклы не перекрываются. Никогда не бросает исключений наружу.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const { ModelDB, computeScore } = require('./modeldb');
|
|
9
|
+
const modelscan = require('./modelscan');
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
enabled: true,
|
|
13
|
+
intervalHours: 6,
|
|
14
|
+
autoAdd: true,
|
|
15
|
+
maxTestParallel: 5,
|
|
16
|
+
maxTestPerCycle: 80,
|
|
17
|
+
recheckDisabledDays: 7,
|
|
18
|
+
testTimeoutMs: 20000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function readJsonSafe(path) {
|
|
22
|
+
try { return JSON.parse(fs.readFileSync(path, 'utf8')); } catch { return {}; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inferSource(providerEntry) {
|
|
26
|
+
const env = providerEntry.envVar || '';
|
|
27
|
+
if (env.includes('OPENROUTER')) return 'openrouter';
|
|
28
|
+
if (env.includes('HF')) return 'huggingface';
|
|
29
|
+
const ep = providerEntry.endpoint || '';
|
|
30
|
+
if (ep.includes('api.groq.com')) return 'groq';
|
|
31
|
+
if (ep.includes('api.mistral.ai')) return 'mistral';
|
|
32
|
+
if (ep.includes('generativelanguage')) return 'gemini';
|
|
33
|
+
if (ep.includes('api.cerebras.ai')) return 'cerebras';
|
|
34
|
+
if (ep.includes('api.deepseek.com')) return 'deepseek';
|
|
35
|
+
if (ep.includes('integrate.api.nvidia')) return 'nim';
|
|
36
|
+
return 'unknown';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function keyFor(source, model) {
|
|
40
|
+
const prefixMap = { openrouter: 'or-', huggingface: 'hf-' };
|
|
41
|
+
const prefix = prefixMap[source] || source + '-';
|
|
42
|
+
return prefix + String(model).split('/').pop().replace(/[:.]/g, '-').slice(0, 30);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Гарантированно-бесплатная ли модель для автодобавления.
|
|
46
|
+
// :free (OpenRouter) — всегда. Нatивные источники — только известные бесплатные
|
|
47
|
+
// семейства; иначе риск добавить платную модель, на которой авто-тест прошёл
|
|
48
|
+
// (mistral-medium, deepseek-v4-pro, GLM-5.2), и она попадёт в роутинг.
|
|
49
|
+
// Источники с большим лимитом (groq/cerebras/gemini) реально бесплатны — там
|
|
50
|
+
// семейства допустимы. Источники с малым лимитом и платными рядами (hf/nim)
|
|
51
|
+
// — только явно безопасные free-семейства (gemma/qwen/llama/nano/ministral).
|
|
52
|
+
function isAutoAddable(candidate) {
|
|
53
|
+
const model = String(candidate.model || '');
|
|
54
|
+
if (model.includes(':free')) return true;
|
|
55
|
+
const source = candidate.source;
|
|
56
|
+
// Явно платные модели — никогда (даже если авто-тест прошёл на балансе).
|
|
57
|
+
if (/(mistral-medium|mistral-large|magistral|deepseek-v4-pro|GLM-5\.|-v4-pro\b)/i.test(model)) return false;
|
|
58
|
+
if (['groq', 'cerebras', 'gemini'].includes(source)) {
|
|
59
|
+
return /(qwen|llama|gemma|gpt-oss|nemotron|mini|compound)/i.test(model);
|
|
60
|
+
}
|
|
61
|
+
if (source === 'nim') {
|
|
62
|
+
// NIM — стабильно бесплатный тариф: какие-то из этих семейств по free.
|
|
63
|
+
return /(nemotron|llama|kimi|minimax|laguna|gemma|qwen|deepseek)/i.test(model);
|
|
64
|
+
}
|
|
65
|
+
// hf и прочие осторожные — только проверенные free-семейства
|
|
66
|
+
return /(gemma|qwen|llama-3\.1|ministral|nano)/i.test(model);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class ModelManager {
|
|
70
|
+
constructor({ dbPath, catalogPath, configPath, keys = {}, fetchImpl, config = {}, reload = null, now = () => Date.now(), log = () => {} }) {
|
|
71
|
+
this.dbPath = dbPath;
|
|
72
|
+
this.catalogPath = catalogPath;
|
|
73
|
+
this.configPath = configPath;
|
|
74
|
+
this.keys = keys;
|
|
75
|
+
this.fetchImpl = fetchImpl;
|
|
76
|
+
this.config = { ...DEFAULTS, ...config };
|
|
77
|
+
this.reload = reload;
|
|
78
|
+
this.now = now;
|
|
79
|
+
this.log = log;
|
|
80
|
+
this.db = new ModelDB(dbPath);
|
|
81
|
+
this.db.load();
|
|
82
|
+
this._running = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Наполнение db из providers.json (idempotent: только отсутствующие ключи).
|
|
86
|
+
// enabled:false в config.json = выбор пользователя → статус user-disabled,
|
|
87
|
+
// такие никогда не перепроверяются и не реактивируются автоматически.
|
|
88
|
+
seed() {
|
|
89
|
+
const catalog = readJsonSafe(this.catalogPath);
|
|
90
|
+
const userCfg = readJsonSafe(this.configPath);
|
|
91
|
+
let added = 0;
|
|
92
|
+
for (const [key, p] of Object.entries(catalog)) {
|
|
93
|
+
if (!p || !p.model || !p.endpoint) continue;
|
|
94
|
+
if (this.db.get(key)) continue;
|
|
95
|
+
const userDisabled = userCfg.providers?.[key]?.enabled === false;
|
|
96
|
+
this.db.upsert({
|
|
97
|
+
key,
|
|
98
|
+
model: p.model,
|
|
99
|
+
endpoint: p.endpoint,
|
|
100
|
+
source: inferSource(p),
|
|
101
|
+
category: p.category || modelscan.classifyModel(p.model),
|
|
102
|
+
contextWindow: p.context_window || 0,
|
|
103
|
+
dailyLimit: p.dailyLimit || 0,
|
|
104
|
+
status: userDisabled ? 'user-disabled' : 'untested',
|
|
105
|
+
seeded: true,
|
|
106
|
+
});
|
|
107
|
+
added++;
|
|
108
|
+
}
|
|
109
|
+
if (added > 0) this.db.save();
|
|
110
|
+
return added;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
apiKeyFor(source) {
|
|
114
|
+
return this.keys[source] || process.env[modelscan.SOURCE_META[source]?.envVar] || '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 1. Проверка активных/untested моделей. dead (404/402) → enabled:false в конфиге.
|
|
118
|
+
async checkExisting() {
|
|
119
|
+
let checked = 0, disabledNow = 0, configChanged = false;
|
|
120
|
+
const userCfg = readJsonSafe(this.configPath);
|
|
121
|
+
for (const m of this.db.all()) {
|
|
122
|
+
if (m.status !== 'active' && m.status !== 'untested') continue;
|
|
123
|
+
const test = await modelscan.testModel(m.endpoint, m.model, this.apiKeyFor(m.source), { timeout: this.config.testTimeoutMs, fetchImpl: this.fetchImpl });
|
|
124
|
+
const before = m.status;
|
|
125
|
+
this.db.markChecked(m.key, test, { now: this.now() });
|
|
126
|
+
checked++;
|
|
127
|
+
if (before !== 'dead' && this.db.get(m.key).status === 'dead') {
|
|
128
|
+
if (!userCfg.providers) userCfg.providers = {};
|
|
129
|
+
userCfg.providers[m.key] = { ...(userCfg.providers[m.key] || {}), enabled: false };
|
|
130
|
+
configChanged = true;
|
|
131
|
+
disabledNow++;
|
|
132
|
+
this.log(` ❌ ${m.key} (${m.model}): ${test.status} — отключаю`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (configChanged) fs.writeFileSync(this.configPath, JSON.stringify(userCfg, null, 2));
|
|
136
|
+
return { checked, disabledNow, configChanged };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Перепроверка мёртвых старше recheckDisabledDays. Ожил → active + enabled:true.
|
|
140
|
+
async recheckDisabled() {
|
|
141
|
+
let rechecked = 0, reenabled = 0, configChanged = false;
|
|
142
|
+
const cutoff = this.config.recheckDisabledDays * 86400000;
|
|
143
|
+
const now = this.now();
|
|
144
|
+
const userCfg = readJsonSafe(this.configPath);
|
|
145
|
+
for (const m of this.db.all()) {
|
|
146
|
+
if (m.status !== 'dead') continue;
|
|
147
|
+
if (!m.lastCheckedAt || now - m.lastCheckedAt < cutoff) continue;
|
|
148
|
+
rechecked++;
|
|
149
|
+
const test = await modelscan.testModel(m.endpoint, m.model, this.apiKeyFor(m.source), { timeout: this.config.testTimeoutMs, fetchImpl: this.fetchImpl });
|
|
150
|
+
this.db.markChecked(m.key, test, { now: this.now() });
|
|
151
|
+
if (test.ok) {
|
|
152
|
+
if (!userCfg.providers) userCfg.providers = {};
|
|
153
|
+
userCfg.providers[m.key] = { ...(userCfg.providers[m.key] || {}), enabled: true };
|
|
154
|
+
configChanged = true;
|
|
155
|
+
reenabled++;
|
|
156
|
+
this.log(` ♻️ ${m.key}: ожил — реактивирован`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (configChanged) fs.writeFileSync(this.configPath, JSON.stringify(userCfg, null, 2));
|
|
160
|
+
return { rechecked, reenabled, configChanged };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. Скан новых + параллельный тест + добавление рабочих.
|
|
164
|
+
async scanAndAdd() {
|
|
165
|
+
const catalog = readJsonSafe(this.catalogPath);
|
|
166
|
+
const existing = new Set(Object.values(catalog).map(p => p.model));
|
|
167
|
+
for (const m of this.db.all()) existing.add(m.model);
|
|
168
|
+
|
|
169
|
+
const candidates = await modelscan.scanSources({
|
|
170
|
+
keys: this.keys, fetchImpl: this.fetchImpl, existing, log: this.log,
|
|
171
|
+
});
|
|
172
|
+
let added = 0, catalogChanged = false;
|
|
173
|
+
if (candidates.length === 0) return { scanned: 0, tested: 0, added, catalogChanged };
|
|
174
|
+
|
|
175
|
+
if (!this.config.autoAdd) {
|
|
176
|
+
this.log(` ⏭ AUTO_ADD=false — ${candidates.length} кандидатов не тестирую`);
|
|
177
|
+
return { scanned: candidates.length, tested: 0, added, catalogChanged };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Кап тестов за цикл: не жжём лимиты, если источник отдал сотни кандидатов.
|
|
181
|
+
// Сначала тестируем гарантированно-бесплатных (:free), затем остальных.
|
|
182
|
+
const freeFirst = [...candidates].sort((a, b) =>
|
|
183
|
+
((b.model.includes(':free') ? 1 : 0) - (a.model.includes(':free') ? 1 : 0)));
|
|
184
|
+
const toTest = freeFirst.slice(0, this.config.maxTestPerCycle);
|
|
185
|
+
const tested = await modelscan.testCandidates(toTest, {
|
|
186
|
+
keys: this.keys, fetchImpl: this.fetchImpl,
|
|
187
|
+
concurrency: this.config.maxTestParallel, timeout: this.config.testTimeoutMs, log: this.log,
|
|
188
|
+
});
|
|
189
|
+
for (const c of tested) {
|
|
190
|
+
if (!c.test.ok) continue; // 429/сбой — не добавляем
|
|
191
|
+
// Анти-мусор: автодобавляем только гарантированно-бесплатные модели.
|
|
192
|
+
// OpenRouter — только :free. Остальные источники (groq/cerebras/gemini/
|
|
193
|
+
// nim/hf) — только если мы уже знаем модель как бесплатную (есть в
|
|
194
|
+
// исходном каталоге/белом списке) или это явно free. Иначе не добавляем:
|
|
195
|
+
// авто-тест может пройти на платной модели и она попадёт в роутинг.
|
|
196
|
+
if (!isAutoAddable(c)) {
|
|
197
|
+
this.log(` ⏭ ${c.model}: не бесплатная — пропуск (report-only)`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const key = keyFor(c.source, c.model);
|
|
201
|
+
this.db.upsert({
|
|
202
|
+
key, model: c.model, endpoint: c.endpoint, source: c.source,
|
|
203
|
+
category: c.category || modelscan.classifyModel(c.model),
|
|
204
|
+
contextWindow: c.contextWindow || 0, dailyLimit: c.dailyLimit || 0,
|
|
205
|
+
meta: c.meta || {},
|
|
206
|
+
});
|
|
207
|
+
this.db.markChecked(key, c.test, { now: this.now() });
|
|
208
|
+
catalog[key] = {
|
|
209
|
+
endpoint: c.endpoint,
|
|
210
|
+
model: c.model,
|
|
211
|
+
priority: 99, // writeBackPriorities проставит ранг по скору
|
|
212
|
+
dailyLimit: c.dailyLimit || 0,
|
|
213
|
+
keyHint: `${c.source} → Keys (автодобавлено, ${c.category})`,
|
|
214
|
+
envVar: modelscan.SOURCE_META[c.source]?.envVar || '',
|
|
215
|
+
free: true,
|
|
216
|
+
category: c.category || 'general',
|
|
217
|
+
};
|
|
218
|
+
added++;
|
|
219
|
+
catalogChanged = true;
|
|
220
|
+
}
|
|
221
|
+
if (catalogChanged) {
|
|
222
|
+
fs.writeFileSync(this.catalogPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
223
|
+
}
|
|
224
|
+
return { scanned: candidates.length, tested: tested.length, added, catalogChanged };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 4. Скор по истории проверок (successRate + латентность + окно + свежесть).
|
|
228
|
+
updateScores() {
|
|
229
|
+
const now = this.now();
|
|
230
|
+
for (const m of this.db.all()) {
|
|
231
|
+
const history = m.checkHistory || [];
|
|
232
|
+
let successRate = 0.5;
|
|
233
|
+
if (history.length > 0) {
|
|
234
|
+
successRate = history.filter(h => h.ok).length / history.length;
|
|
235
|
+
}
|
|
236
|
+
const okLat = history.filter(h => h.ok && h.latencyMs > 0);
|
|
237
|
+
const latencyEma = okLat.length > 0
|
|
238
|
+
? okLat.reduce((a, h) => a + h.latencyMs, 0) / okLat.length
|
|
239
|
+
: 0;
|
|
240
|
+
m.score = computeScore({ successRate, latencyEma, contextWindow: m.contextWindow, lastOkAt: m.lastOkAt, now });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 5. Priority write-back: только auto-managed ключи (не seeded). Ранг в
|
|
245
|
+
// категории по скору (1 = лучший). Ручные записи каталога не трогаем.
|
|
246
|
+
writeBackPriorities() {
|
|
247
|
+
const catalog = readJsonSafe(this.catalogPath);
|
|
248
|
+
const byCategory = {};
|
|
249
|
+
for (const m of this.db.all()) {
|
|
250
|
+
if (m.seeded) continue;
|
|
251
|
+
if (!catalog[m.key]) continue;
|
|
252
|
+
(byCategory[m.category] || (byCategory[m.category] = [])).push(m);
|
|
253
|
+
}
|
|
254
|
+
let changed = false;
|
|
255
|
+
for (const entries of Object.values(byCategory)) {
|
|
256
|
+
entries.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
257
|
+
entries.forEach((m, i) => {
|
|
258
|
+
const p = i + 1;
|
|
259
|
+
if (catalog[m.key].priority !== p) {
|
|
260
|
+
catalog[m.key].priority = p;
|
|
261
|
+
changed = true;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (changed) fs.writeFileSync(this.catalogPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
266
|
+
return changed;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Полный цикл. Single-flight.
|
|
270
|
+
async runCycle() {
|
|
271
|
+
if (this._running) return { skipped: true };
|
|
272
|
+
this._running = true;
|
|
273
|
+
try {
|
|
274
|
+
const summary = { checkedExisting: 0, disabledNow: 0, rechecked: 0, reenabled: 0, scanned: 0, tested: 0, added: 0, priorityUpdated: false };
|
|
275
|
+
this.seed();
|
|
276
|
+
|
|
277
|
+
const ex = await this.checkExisting();
|
|
278
|
+
summary.checkedExisting = ex.checked;
|
|
279
|
+
summary.disabledNow = ex.disabledNow;
|
|
280
|
+
|
|
281
|
+
const rc = await this.recheckDisabled();
|
|
282
|
+
summary.rechecked = rc.rechecked;
|
|
283
|
+
summary.reenabled = rc.reenabled;
|
|
284
|
+
|
|
285
|
+
const sa = await this.scanAndAdd();
|
|
286
|
+
summary.scanned = sa.scanned;
|
|
287
|
+
summary.tested = sa.tested;
|
|
288
|
+
summary.added = sa.added;
|
|
289
|
+
|
|
290
|
+
this.updateScores();
|
|
291
|
+
this.db.save();
|
|
292
|
+
|
|
293
|
+
const prioritiesChanged = this.writeBackPriorities();
|
|
294
|
+
summary.priorityUpdated = prioritiesChanged;
|
|
295
|
+
|
|
296
|
+
const catalogChanged = sa.catalogChanged || prioritiesChanged;
|
|
297
|
+
if (catalogChanged || rc.configChanged || ex.configChanged) {
|
|
298
|
+
if (this.reload) {
|
|
299
|
+
try { this.reload(); } catch {}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return summary;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this.log(` цикл прерван: ${err.message}`);
|
|
305
|
+
return { error: err.message };
|
|
306
|
+
} finally {
|
|
307
|
+
this._running = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// «Всегда»: стартовый отложенный прогон + интервал с джиттером.
|
|
312
|
+
start() {
|
|
313
|
+
if (!this.config.enabled) return null;
|
|
314
|
+
const jitter = (min, max) => min + Math.random() * (max - min);
|
|
315
|
+
const intervalMs = this.config.intervalHours * 3600000;
|
|
316
|
+
this._timer = setInterval(() => {
|
|
317
|
+
this.runCycle().catch(() => {});
|
|
318
|
+
}, intervalMs + jitter(-600000, 600000)); // ±10 мин джиттер
|
|
319
|
+
this._timer.unref?.();
|
|
320
|
+
// Первый прогон через 2 минуты после старта (пусть сервер поднимется).
|
|
321
|
+
this._initial = setTimeout(() => {
|
|
322
|
+
this.runCycle().catch(() => {});
|
|
323
|
+
}, 120000);
|
|
324
|
+
this._initial.unref?.();
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
stop() {
|
|
329
|
+
if (this._timer) clearInterval(this._timer);
|
|
330
|
+
if (this._initial) clearTimeout(this._initial);
|
|
331
|
+
this._timer = this._initial = null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = { ModelManager, keyFor, inferSource, isAutoAddable, DEFAULTS };
|
package/lib/modelscan.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// lib/modelscan.js — многоисточниковый сканер бесплатных моделей для Freegate.
|
|
2
|
+
// Каждый источник — адаптер, отдающий нормализованных кандидатов:
|
|
3
|
+
// { model, source, endpoint, contextWindow, dailyLimit, category }.
|
|
4
|
+
// Вся сеть через injectable fetchImpl — юнит-тесты ходят в моки, не в сеть.
|
|
5
|
+
|
|
6
|
+
// Источники для автопоиска. Исключены mistral/deepseek: их каталог включает
|
|
7
|
+
// платные модели, и авто-тест может «пройти» на платной модели, после чего
|
|
8
|
+
// она попадает в роутинг и жжёт деньги/ломается. Надёжно-бесплатные: openrouter
|
|
9
|
+
// (только :free), groq, cerebras, gemini (free tier), nim (малый лимит, но
|
|
10
|
+
// стабильно бесплатный), huggingface. Каталог расширяется вручную через
|
|
11
|
+
// config.json/providers.json.
|
|
12
|
+
const SOURCES = ['openrouter', 'huggingface', 'groq', 'gemini', 'cerebras', 'nim'];
|
|
13
|
+
|
|
14
|
+
// Endpoints списков моделей и chat-completions по источникам.
|
|
15
|
+
const SOURCE_META = {
|
|
16
|
+
openrouter: { list: 'https://openrouter.ai/api/v1/models', chat: 'https://openrouter.ai/api/v1/chat/completions', envVar: 'PROVIDER_OPENROUTER_APIKEY', dailyLimit: 50 },
|
|
17
|
+
huggingface: { list: 'https://router.huggingface.co/v1/models', chat: 'https://router.huggingface.co/v1/chat/completions', envVar: 'PROVIDER_HF_APIKEY', dailyLimit: 200 },
|
|
18
|
+
groq: { list: 'https://api.groq.com/openai/v1/models', chat: 'https://api.groq.com/openai/v1/chat/completions', envVar: 'PROVIDER_GROQ_APIKEY', dailyLimit: 1000 },
|
|
19
|
+
mistral: { list: 'https://api.mistral.ai/v1/models', chat: 'https://api.mistral.ai/v1/chat/completions', envVar: 'PROVIDER_MISTRAL_APIKEY', dailyLimit: 500 },
|
|
20
|
+
gemini: { list: 'https://generativelanguage.googleapis.com/v1beta/openai/models', chat: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', envVar: 'PROVIDER_GEMINI_APIKEY', dailyLimit: 1500 },
|
|
21
|
+
cerebras: { list: 'https://api.cerebras.ai/v1/models', chat: 'https://api.cerebras.ai/v1/chat/completions', envVar: 'PROVIDER_CEREBRAS_APIKEY', dailyLimit: 1000 },
|
|
22
|
+
deepseek: { list: 'https://api.deepseek.com/models', chat: 'https://api.deepseek.com/chat/completions', envVar: 'PROVIDER_DEEPSEEK_APIKEY', dailyLimit: 1000 },
|
|
23
|
+
nim: { list: 'https://integrate.api.nvidia.com/v1/models', chat: 'https://integrate.api.nvidia.com/v1/chat/completions', envVar: 'PROVIDER_NIM_APIKEY', dailyLimit: 40 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Мусор: эмбеддинги, реранки, аудио/речь/музыка, guardrail, картинки/видео,
|
|
27
|
+
// робототехника, computer-use, калибровки. Всё, что не plain-chat.
|
|
28
|
+
const JUNK_RE = /embed|rerank|whisper|tts|audio|transcrib|speech|riva|lyria|guard|safety|moder|diffusion|stable-|\/sd[-_]|flux|image|img|banana|veo|imagen|robot|computer-use|calibrat|bert|clip\b|live|realtime|dubbing/i;
|
|
29
|
+
// HF: только известные chat-семейства (иначе сотни нерелевантных репо).
|
|
30
|
+
const HF_FAMILY_RE = /(qwen|llama|glm|gemma|mistral|deepseek|nemotron|gpt-oss|phi|command|aya|falcon|olmo)/i;
|
|
31
|
+
|
|
32
|
+
function isJunkModel(id) {
|
|
33
|
+
if (!id) return true;
|
|
34
|
+
return JUNK_RE.test(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Gemini отдаёт id с префиксом 'models/' — нормализуем (OpenAI-compat endpoint
|
|
38
|
+
// принимает id без префикса, и это же устраняет ложный dedup с каталогом).
|
|
39
|
+
function normalizeModelId(id, source) {
|
|
40
|
+
let out = String(id || '');
|
|
41
|
+
if (source === 'gemini') out = out.replace(/^models\//, '');
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Категория по id/описанию: vision > coding > reasoning > general.
|
|
46
|
+
function classifyModel(id, desc = '') {
|
|
47
|
+
const m = String(id || '').toLowerCase();
|
|
48
|
+
const d = String(desc || '').toLowerCase();
|
|
49
|
+
if (/-vl\b|vision|omni|multimodal/.test(m) || /image input|multimodal/.test(d)) return 'vision';
|
|
50
|
+
if (/codestral|coder|code\b|devstral|starcoder/.test(m) || /coding/.test(d)) return 'coding';
|
|
51
|
+
if (/r1\b|qwq|think|reason/.test(m) || /reasoning|agentic/.test(d)) return 'reasoning';
|
|
52
|
+
return 'general';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fetchModelsList(url, apiKey, fetchImpl, timeout = 15000) {
|
|
56
|
+
return fetchImpl(url, {
|
|
57
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
|
58
|
+
signal: AbortSignal.timeout(timeout),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Адаптеры: list-ответ ({data:[{id, context_length?, description?}]}) → кандидаты.
|
|
63
|
+
const ADAPTERS = {
|
|
64
|
+
openrouter: async (ctx) => {
|
|
65
|
+
const res = await fetchModelsList(SOURCE_META.openrouter.list, '', ctx.fetch);
|
|
66
|
+
if (!res.ok) return [];
|
|
67
|
+
const data = await res.json().catch(() => ({}));
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const m of (data.data || [])) {
|
|
70
|
+
const id = normalizeModelId(m.id, 'openrouter');
|
|
71
|
+
if (!id || !id.endsWith(':free')) continue;
|
|
72
|
+
if (ctx.existing.has(id) || isJunkModel(id)) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
model: id, source: 'openrouter', endpoint: SOURCE_META.openrouter.chat,
|
|
75
|
+
contextWindow: m.context_length || 0, dailyLimit: SOURCE_META.openrouter.dailyLimit,
|
|
76
|
+
category: classifyModel(id, m.description || ''), meta: { description: (m.description || '').slice(0, 200) },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
huggingface: async (ctx) => {
|
|
83
|
+
const key = ctx.keys.huggingface || '';
|
|
84
|
+
if (!key) return [];
|
|
85
|
+
const res = await fetchModelsList(SOURCE_META.huggingface.list, key, ctx.fetch);
|
|
86
|
+
if (!res.ok) return [];
|
|
87
|
+
const data = await res.json().catch(() => ({}));
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const m of (data.data || [])) {
|
|
90
|
+
const id = normalizeModelId(m.id, 'huggingface');
|
|
91
|
+
if (!id || ctx.existing.has(id) || isJunkModel(id)) continue;
|
|
92
|
+
if (!HF_FAMILY_RE.test(id)) continue;
|
|
93
|
+
out.push({
|
|
94
|
+
model: id, source: 'huggingface', endpoint: SOURCE_META.huggingface.chat,
|
|
95
|
+
contextWindow: m.context_length || 0, dailyLimit: SOURCE_META.huggingface.dailyLimit,
|
|
96
|
+
category: classifyModel(id),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Нативные OpenAI-совместимые списки: один паттерн на все источники.
|
|
104
|
+
for (const name of ['groq', 'mistral', 'gemini', 'cerebras', 'deepseek', 'nim']) {
|
|
105
|
+
ADAPTERS[name] = async (ctx) => {
|
|
106
|
+
const meta = SOURCE_META[name];
|
|
107
|
+
const key = ctx.keys[name] || process.env[meta.envVar] || '';
|
|
108
|
+
if (!key) return [];
|
|
109
|
+
const res = await fetchModelsList(meta.list, key, ctx.fetch);
|
|
110
|
+
if (!res.ok) return [];
|
|
111
|
+
const data = await res.json().catch(() => ({}));
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const m of (data.data || [])) {
|
|
114
|
+
const id = normalizeModelId(typeof m === 'string' ? m : m.id, name);
|
|
115
|
+
if (!id || ctx.existing.has(id) || isJunkModel(id)) continue;
|
|
116
|
+
out.push({
|
|
117
|
+
model: id, source: name, endpoint: meta.chat,
|
|
118
|
+
contextWindow: (typeof m === 'object' && m.context_length) || 0,
|
|
119
|
+
dailyLimit: meta.dailyLimit,
|
|
120
|
+
category: classifyModel(id),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Скан источников: кандидаты, dedup по model id (первый источник в списке выигрывает).
|
|
128
|
+
async function scanSources({ sources = SOURCES, keys = {}, fetchImpl, existing = new Set(), log = () => {} } = {}) {
|
|
129
|
+
if (!fetchImpl) throw new Error('modelscan: fetchImpl is required');
|
|
130
|
+
const seen = new Set(existing);
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const name of sources) {
|
|
133
|
+
const adapter = ADAPTERS[name];
|
|
134
|
+
if (!adapter) continue;
|
|
135
|
+
try {
|
|
136
|
+
const candidates = await adapter({ keys, fetch: fetchImpl, existing: seen });
|
|
137
|
+
let fresh = 0;
|
|
138
|
+
for (const c of candidates) {
|
|
139
|
+
if (seen.has(c.model)) continue; // dedup между источниками
|
|
140
|
+
seen.add(c.model);
|
|
141
|
+
out.push(c);
|
|
142
|
+
fresh++;
|
|
143
|
+
}
|
|
144
|
+
if (fresh > 0) log(` ${name}: ${fresh} новых кандидатов`);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
log(` ${name}: ошибка скана — ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Реальный тест модели: chat/completions "hi", max_tokens 5. HTTP 200 = жива.
|
|
153
|
+
async function testModel(endpoint, model, apiKey, { timeout = 20000, fetchImpl } = {}) {
|
|
154
|
+
const t0 = Date.now();
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetchImpl(endpoint, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
|
|
159
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 5 }),
|
|
160
|
+
signal: AbortSignal.timeout(timeout),
|
|
161
|
+
});
|
|
162
|
+
if (!res.ok) return { ok: false, status: res.status, latencyMs: Date.now() - t0 };
|
|
163
|
+
await res.json().catch(() => {});
|
|
164
|
+
return { ok: true, status: res.status, latencyMs: Date.now() - t0 };
|
|
165
|
+
} catch {
|
|
166
|
+
return { ok: false, status: 0, latencyMs: Date.now() - t0 };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Параллельный тест кандидатов с ограничением одновременности (worker pool).
|
|
171
|
+
async function testCandidates(candidates, { keys = {}, fetchImpl, concurrency = 5, timeout = 20000, log = () => {} } = {}) {
|
|
172
|
+
if (!fetchImpl) throw new Error('modelscan: fetchImpl is required');
|
|
173
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return [];
|
|
174
|
+
const results = [];
|
|
175
|
+
let idx = 0;
|
|
176
|
+
async function worker() {
|
|
177
|
+
while (idx < candidates.length) {
|
|
178
|
+
const c = candidates[idx++];
|
|
179
|
+
const apiKey = keys[c.source] || process.env[SOURCE_META[c.source]?.envVar] || '';
|
|
180
|
+
const test = await testModel(c.endpoint, c.model, apiKey, { timeout, fetchImpl });
|
|
181
|
+
if (test.ok) log(` ✅ ${c.model} (${c.category})`);
|
|
182
|
+
else if (test.status === 429) log(` ⏳ ${c.model}: 429 (лимит)`);
|
|
183
|
+
results.push({ ...c, test });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker));
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { SOURCES, SOURCE_META, ADAPTERS, isJunkModel, classifyModel, normalizeModelId, scanSources, testModel, testCandidates };
|
package/lib/normalize.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Сравниваются только пользовательские текстовые сообщения, без system.
|
|
3
3
|
|
|
4
4
|
const MAX_LEN = 2000;
|
|
5
|
+
const TAIL_LEN = 400; // последние N символов диалога — всегда в ключе, чтобы
|
|
6
|
+
// два диалога с общим длинным префиксом не схлопывались
|
|
7
|
+
// в один кэш-ключ (иначе кэш отдаёт ответ из чужого чата).
|
|
5
8
|
|
|
6
9
|
const CODE_SYMBOLS = new Set(['{', '}', '[', ']', '=>', ';', '=', '+', '-', '*', '/', '<', '>']);
|
|
7
10
|
const CODE_KEYWORDS = new Set([
|
|
@@ -78,12 +81,19 @@ function normalizeMessages(messages) {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
function normalizeText(text) {
|
|
81
|
-
|
|
84
|
+
const cleaned = (text || '')
|
|
82
85
|
.toLowerCase()
|
|
83
86
|
.replace(/[\p{P}\p{S}]+/gu, ' ') // пунктуация и символы → пробел
|
|
84
87
|
.replace(/\s+/g, ' ') // сжать пробелы
|
|
85
|
-
.trim()
|
|
86
|
-
|
|
88
|
+
.trim();
|
|
89
|
+
// Хэш-ключ = начало (первые MAX_LEN) + конец (последние TAIL_LEN).
|
|
90
|
+
// Если просто обрезать до MAX_LEN, два длинных диалога с одинаковым
|
|
91
|
+
// префиксом совпадут — кэш отдаст ответ из чужого чата. Хвост диалога
|
|
92
|
+
// (последние сообщения) почти всегда различает их.
|
|
93
|
+
const tail = cleaned.length > MAX_LEN
|
|
94
|
+
? cleaned.slice(-TAIL_LEN)
|
|
95
|
+
: '';
|
|
96
|
+
return cleaned.slice(0, MAX_LEN) + tail;
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
module.exports = { normalizeMessages, normalizeText, looksLikeCode, MAX_LEN };
|
|
99
|
+
module.exports = { normalizeMessages, normalizeText, looksLikeCode, MAX_LEN, TAIL_LEN };
|