freegate 0.6.10 → 0.6.11
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/lib/routing.js +21 -1
- package/package.json +1 -1
- package/server.js +51 -22
package/lib/routing.js
CHANGED
|
@@ -57,4 +57,24 @@ function maybeUpgradeTier(requestedModel, complexity) {
|
|
|
57
57
|
return requestedModel;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// Сложность СКРИНШОТА по распознанному тексту. Скриншоты с кодом/ошибками
|
|
61
|
+
// должны поднимать тир (тяжёлая модель), простые фото — оставаться на лёгком.
|
|
62
|
+
function classifyVisionComplexity(text) {
|
|
63
|
+
if (!text || typeof text !== 'string') return 0;
|
|
64
|
+
const t = text.trim();
|
|
65
|
+
if (t.length === 0) return 0;
|
|
66
|
+
let score = 0;
|
|
67
|
+
// Длина распознанного текста (до +0.2)
|
|
68
|
+
score += Math.min(t.length / 3000, 0.2);
|
|
69
|
+
// Код-признаки (до +0.4)
|
|
70
|
+
const codeMatches = (t.match(CODE_WORDS_EN) || []).length;
|
|
71
|
+
const ruMatches = (t.match(CODE_WORDS_RU) || []).length;
|
|
72
|
+
score += Math.min((codeMatches + ruMatches) * 0.08, 0.4);
|
|
73
|
+
// Error/fix слова (до +0.3)
|
|
74
|
+
if (FIX_WORDS.test(t)) score += 0.3;
|
|
75
|
+
// Символы кода { } [ ] => (до +0.3)
|
|
76
|
+
if (CODE_SYMBOLS.test(t)) score += 0.3;
|
|
77
|
+
return Math.min(score, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { classifyComplexity, maybeUpgradeTier, COMPLEX_THRESHOLD, classifyVisionComplexity };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freegate",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
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": {
|
package/server.js
CHANGED
|
@@ -9,7 +9,7 @@ const { checkRateLimit } = require('./lib/rateLimit');
|
|
|
9
9
|
const { handleDashboard } = require('./lib/dashboard');
|
|
10
10
|
const { acquire, stats: poolStats } = require('./lib/pool');
|
|
11
11
|
const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage, hasContent } = require('./lib/clean');
|
|
12
|
-
const { classifyComplexity, maybeUpgradeTier } = require('./lib/routing');
|
|
12
|
+
const { classifyComplexity, maybeUpgradeTier, classifyVisionComplexity } = require('./lib/routing');
|
|
13
13
|
const logger = require('./lib/logger');
|
|
14
14
|
|
|
15
15
|
// Load persisted state
|
|
@@ -108,12 +108,11 @@ async function checkProvider(key, provider) {
|
|
|
108
108
|
|
|
109
109
|
async function healthCheck() {
|
|
110
110
|
const now = Date.now();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
111
|
+
// Параллельный пробинг: все провайдеры проверяются одновременно,
|
|
112
|
+
// а не последовательно — быстрый старт и восстановление при 30+ провайдерах.
|
|
113
|
+
const due = Object.entries(PROVIDERS)
|
|
114
|
+
.filter(([key, provider]) => provider.enabled && !(healthIntervals[key] && healthIntervals[key].nextCheck > now));
|
|
115
|
+
await Promise.allSettled(due.map(([key, provider]) => checkProvider(key, provider)));
|
|
117
116
|
}
|
|
118
117
|
setInterval(healthCheck, 30000);
|
|
119
118
|
setTimeout(healthCheck, 1000);
|
|
@@ -123,7 +122,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
123
122
|
const requestedModel = body.model || 'tier-splus';
|
|
124
123
|
// Умный роутинг: сложные задачи с лёгкого тира поднимаем на более мощный.
|
|
125
124
|
// Классифицируем ПОСЛЕ того, как определён requestedModel, ДО выбора провайдера.
|
|
126
|
-
|
|
125
|
+
let effectiveModel = maybeUpgradeTier(requestedModel, classifyComplexity(body.messages));
|
|
127
126
|
let targetProviderKey = MODEL_MAP[effectiveModel] || MODEL_MAP[requestedModel] || 'zai';
|
|
128
127
|
const isStreaming = body.stream === true;
|
|
129
128
|
|
|
@@ -191,6 +190,11 @@ async function handleChatCompletion(req, res, body) {
|
|
|
191
190
|
const cleaned = stripThink(extracted, true);
|
|
192
191
|
logger.info('Vision pipeline: скриншот распознан', { chars: cleaned.length });
|
|
193
192
|
if (cleaned) {
|
|
193
|
+
// Умный vision-роутинг: скриншот с кодом/ошибкой поднимает тир.
|
|
194
|
+
const vc = classifyVisionComplexity(cleaned);
|
|
195
|
+
if (vc > 0) effectiveModel = maybeUpgradeTier(effectiveModel, vc);
|
|
196
|
+
// Пересчитываем target-провайдера — vision-апгрейд мог сменить тир.
|
|
197
|
+
targetProviderKey = MODEL_MAP[effectiveModel] || MODEL_MAP[requestedModel] || 'zai';
|
|
194
198
|
// Replace image content with the extracted text as context,
|
|
195
199
|
// so the coding/general model (not vision) answers the question.
|
|
196
200
|
const userMsgs = Array.isArray(body.messages) ? body.messages : [];
|
|
@@ -346,6 +350,26 @@ async function handleChatCompletion(req, res, body) {
|
|
|
346
350
|
// provider speed. Huge latency would poison the weighted selection.
|
|
347
351
|
getHealth()[key].latency = Math.min(result.latency || 0, 60000);
|
|
348
352
|
getHealth()[key].lastCheck = Date.now();
|
|
353
|
+
|
|
354
|
+
// For non-stream, verify the response isn't empty BEFORE recording success.
|
|
355
|
+
if (!isStreaming && result.data) {
|
|
356
|
+
delete result.data.nvext;
|
|
357
|
+
if (result.data.choices?.[0]) {
|
|
358
|
+
fixReasoningMessage(result.data.choices[0].message);
|
|
359
|
+
cleanMessage(result.data.choices[0].message);
|
|
360
|
+
}
|
|
361
|
+
if (!hasContent(result.data)) {
|
|
362
|
+
// Пустой ответ (провайдер-глитч) НЕ считается успехом — пробуем следующего.
|
|
363
|
+
const msg = key + ': empty response';
|
|
364
|
+
errors.push(msg);
|
|
365
|
+
recordFailure(key, 0);
|
|
366
|
+
recordRequest(key, false, msg);
|
|
367
|
+
recordRecent({ model: requestedModel, provider: key, status: 204, latency: result.latency, cached: false });
|
|
368
|
+
logger.warn('Empty response, trying next provider', { key });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
349
373
|
recordSuccess(key);
|
|
350
374
|
recordRequest(key, true);
|
|
351
375
|
logger.request({ model: requestedModel, provider: key, status: 200, latency: result.latency, stream: isStreaming });
|
|
@@ -413,12 +437,8 @@ async function handleChatCompletion(req, res, body) {
|
|
|
413
437
|
}
|
|
414
438
|
|
|
415
439
|
if (!isStreaming && result.data) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
fixReasoningMessage(result.data.choices[0].message);
|
|
419
|
-
cleanMessage(result.data.choices[0].message);
|
|
420
|
-
}
|
|
421
|
-
if (hasContent(result.data)) cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
440
|
+
// content already verified non-empty above
|
|
441
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
422
442
|
recordTokens(key, result.usage);
|
|
423
443
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
424
444
|
res.end(JSON.stringify(result.data));
|
|
@@ -470,22 +490,31 @@ async function handleChatCompletion(req, res, body) {
|
|
|
470
490
|
} finally {
|
|
471
491
|
release();
|
|
472
492
|
}
|
|
473
|
-
recordSuccess(key);
|
|
474
|
-
recordRequest(key, true);
|
|
475
|
-
recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
|
|
476
493
|
if (!body.stream && result.data) {
|
|
477
494
|
delete result.data.nvext;
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (hasContent(result.data))
|
|
495
|
+
if (result.data.choices?.[0]) {
|
|
496
|
+
fixReasoningMessage(result.data.choices[0].message);
|
|
497
|
+
cleanMessage(result.data.choices[0].message);
|
|
498
|
+
}
|
|
499
|
+
if (!hasContent(result.data)) {
|
|
500
|
+
recordFailure(key, 0);
|
|
501
|
+
recordRequest(key, false, key + ': empty response (retry)');
|
|
502
|
+
logger.warn('Empty response in retry, trying next', { key });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
recordSuccess(key);
|
|
506
|
+
recordRequest(key, true);
|
|
507
|
+
recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
|
|
508
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
483
509
|
recordTokens(key, result.usage);
|
|
484
510
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
485
511
|
res.end(JSON.stringify(result.data));
|
|
486
512
|
return;
|
|
487
513
|
}
|
|
488
514
|
if (body.stream && result.stream) {
|
|
515
|
+
recordSuccess(key);
|
|
516
|
+
recordRequest(key, true);
|
|
517
|
+
recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
|
|
489
518
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
490
519
|
result.stream.pipe(res);
|
|
491
520
|
return;
|