freegate 0.6.7 → 0.6.9
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/cache.js +14 -2
- package/lib/clean.js +13 -1
- package/lib/normalize.js +74 -0
- package/lib/providers.js +1 -1
- package/lib/routing.js +60 -0
- package/package.json +1 -1
- package/server.js +15 -8
package/lib/cache.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const crypto = require('crypto');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { normalizeMessages } = require('./normalize');
|
|
5
6
|
|
|
6
7
|
const MAX_SIZE = 500;
|
|
7
8
|
const DEFAULT_TTL = 3600000; // 1 hour in ms
|
|
@@ -9,17 +10,28 @@ const MAX_ENTRY_BYTES = 256 * 1024; // don't persist responses larger than 256KB
|
|
|
9
10
|
const CACHE_PATH = path.join(__dirname, '..', 'cache.json');
|
|
10
11
|
|
|
11
12
|
class LRUCache {
|
|
12
|
-
constructor(maxSize = MAX_SIZE, ttl = DEFAULT_TTL, skipLoad = false) {
|
|
13
|
+
constructor(maxSize = MAX_SIZE, ttl = DEFAULT_TTL, skipLoad = false, useNormalize = false) {
|
|
13
14
|
this.maxSize = maxSize;
|
|
14
15
|
this.ttl = ttl;
|
|
15
16
|
this.cache = new Map();
|
|
16
17
|
this.hits = 0;
|
|
17
18
|
this.misses = 0;
|
|
19
|
+
this.useNormalize = useNormalize;
|
|
18
20
|
if (!skipLoad) this.load();
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
_key(model, messages, temperature) {
|
|
22
|
-
|
|
24
|
+
let raw;
|
|
25
|
+
if (this.useNormalize) {
|
|
26
|
+
const norm = normalizeMessages(messages);
|
|
27
|
+
// Пустая нормализация (только картинки/инструменты без текста) НЕ должна
|
|
28
|
+
// схлопывать разные запросы в один ключ — fallback на точное совпадение.
|
|
29
|
+
raw = norm
|
|
30
|
+
? `${model}|${norm}|${temperature || 0}`
|
|
31
|
+
: `${model}|${JSON.stringify(messages)}|${temperature || 0}`;
|
|
32
|
+
} else {
|
|
33
|
+
raw = `${model}|${JSON.stringify(messages)}|${temperature || 0}`;
|
|
34
|
+
}
|
|
23
35
|
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
24
36
|
}
|
|
25
37
|
|
package/lib/clean.js
CHANGED
|
@@ -53,4 +53,16 @@ function cleanDelta(delta) {
|
|
|
53
53
|
return delta;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// True if a non-streaming completion actually contains answer text.
|
|
57
|
+
// Used before caching — empty responses (provider glitch) must NOT be cached,
|
|
58
|
+
// otherwise every similar request returns the empty answer forever.
|
|
59
|
+
function hasContent(data) {
|
|
60
|
+
if (!data || !Array.isArray(data.choices)) return false;
|
|
61
|
+
const msg = data.choices[0]?.message;
|
|
62
|
+
if (!msg) return false;
|
|
63
|
+
const content = typeof msg.content === 'string' ? msg.content.trim() : '';
|
|
64
|
+
const reasoning = typeof msg.reasoning === 'string' ? msg.reasoning.trim() : '';
|
|
65
|
+
return content.length > 0 || reasoning.length > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { stripThink, cleanMessage, cleanDelta, fixReasoningMessage, hasContent };
|
package/lib/normalize.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Нормализация сообщений для семантического кэша.
|
|
2
|
+
// Сравниваются только пользовательские текстовые сообщения, без system.
|
|
3
|
+
|
|
4
|
+
const MAX_LEN = 2000;
|
|
5
|
+
|
|
6
|
+
const CODE_SYMBOLS = new Set(['{', '}', '[', ']', '=>', ';', '=', '+', '-', '*', '/', '<', '>']);
|
|
7
|
+
const CODE_KEYWORDS = new Set([
|
|
8
|
+
'function', 'const', 'let', 'var', 'return', 'import', 'export',
|
|
9
|
+
'class', 'def', '=>', 'await', 'async', 'try', 'catch', 'throw',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function lastUserText(messages) {
|
|
13
|
+
if (!Array.isArray(messages)) return '';
|
|
14
|
+
const users = messages.filter(m => m && m.role === 'user');
|
|
15
|
+
const last = users[users.length - 1];
|
|
16
|
+
if (!last) return '';
|
|
17
|
+
if (typeof last.content === 'string') return last.content;
|
|
18
|
+
if (Array.isArray(last.content)) {
|
|
19
|
+
return last.content.filter(c => c && c.type === 'text').map(c => c.text || '').join(' ');
|
|
20
|
+
}
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function looksLikeCode(messages) {
|
|
25
|
+
const text = lastUserText(messages);
|
|
26
|
+
if (!text) return false;
|
|
27
|
+
if (text.includes('```')) return true;
|
|
28
|
+
|
|
29
|
+
const hasKeyword = [...CODE_KEYWORDS].some(kw => new RegExp(`\\b${kw}\\b`).test(text));
|
|
30
|
+
|
|
31
|
+
// Считаем операторы/символы; стрелку `=>` учитываем как отдельный символ,
|
|
32
|
+
// чтобы `=` и `>` внутри неё не задваивались.
|
|
33
|
+
let symbolCount = 0;
|
|
34
|
+
const rest = text.replace(/=>/g, '');
|
|
35
|
+
if (text.includes('=>')) symbolCount += 1;
|
|
36
|
+
for (const ch of CODE_SYMBOLS) {
|
|
37
|
+
if (ch === '=>') continue;
|
|
38
|
+
symbolCount += rest.split(ch).length - 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (hasKeyword && symbolCount >= 1) return true;
|
|
42
|
+
if (symbolCount >= 3) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeMessages(messages) {
|
|
47
|
+
if (!Array.isArray(messages)) return '';
|
|
48
|
+
const userOnly = messages.filter(m => m && m.role === 'user');
|
|
49
|
+
if (looksLikeCode(messages)) {
|
|
50
|
+
// Код: точное совпадение, чтобы операторы не схлопывались в один ключ.
|
|
51
|
+
return JSON.stringify(userOnly);
|
|
52
|
+
}
|
|
53
|
+
const userTexts = userOnly
|
|
54
|
+
.map(m => {
|
|
55
|
+
if (typeof m.content === 'string') return m.content;
|
|
56
|
+
if (Array.isArray(m.content)) {
|
|
57
|
+
return m.content.filter(c => c && c.type === 'text').map(c => c.text || '').join(' ');
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
})
|
|
61
|
+
.join('\n');
|
|
62
|
+
return normalizeText(userTexts);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeText(text) {
|
|
66
|
+
return (text || '')
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[\p{P}\p{S}]+/gu, ' ') // пунктуация и символы → пробел
|
|
69
|
+
.replace(/\s+/g, ' ') // сжать пробелы
|
|
70
|
+
.trim()
|
|
71
|
+
.slice(0, MAX_LEN);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { normalizeMessages, normalizeText, looksLikeCode, MAX_LEN };
|
package/lib/providers.js
CHANGED
|
@@ -69,7 +69,7 @@ for (const [key, provider] of Object.entries(PROVIDERS)) {
|
|
|
69
69
|
const derivedVar = `PROVIDER_${key.toUpperCase().replace(/-/g, '_')}_APIKEY`;
|
|
70
70
|
const prefix = key.split('-')[0].toUpperCase();
|
|
71
71
|
// Map known prefixes to their real env vars
|
|
72
|
-
const prefixMap = { OR: 'OPENROUTER', NIM: 'NIM', GROQ: 'GROQ', MISTRAL: 'MISTRAL', GEMINI: 'GEMINI', ZAI: 'ZAI' };
|
|
72
|
+
const prefixMap = { OR: 'OPENROUTER', NIM: 'NIM', GROQ: 'GROQ', MISTRAL: 'MISTRAL', GEMINI: 'GEMINI', ZAI: 'ZAI', HF: 'HF' };
|
|
73
73
|
const resolvedPrefix = prefixMap[prefix] || prefix;
|
|
74
74
|
const envKey = process.env[explicitVar] || process.env[derivedVar] || process.env[`PROVIDER_${resolvedPrefix}_APIKEY`];
|
|
75
75
|
if (envKey) provider.apiKey = envKey;
|
package/lib/routing.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Классификация сложности запроса. Возвращает 0..1.
|
|
2
|
+
// Признаки кода: ключевые слова, символы, длина сообщения, спец-слова.
|
|
3
|
+
// Внимание: \b в JS учитывает только ASCII-символы, поэтому для русских
|
|
4
|
+
// основ (словоформы склоняются: «функцию», «сортировки») используется
|
|
5
|
+
// префиксное совпадение без границ слова, а для латинских слов — \b.
|
|
6
|
+
const CODE_WORDS_EN = /\b(?:function|class|const|let|var|import|export|return|await|async|def|int|void|throw|try|catch|code)\b/gi;
|
|
7
|
+
const CODE_WORDS_RU = /(?:функци|сортировк|массив|код|переменн|цикл|класс|скрипт|комментари)/gi;
|
|
8
|
+
const CODE_SYMBOLS = /[{}\[\]]|=>/;
|
|
9
|
+
const FIX_WORDS = /(?:ошибк|баг|не работает|рефакторинг|исправь|почини|оптимизир)|\b(?:fix|bug|error|exception|refactor|debug)\b/i;
|
|
10
|
+
const REASONING_WORDS = /(?:объясни|почему|зачем|проанализируй|сравни|докажи|спроектируй|архитектур)/i;
|
|
11
|
+
|
|
12
|
+
const CODE_MATCH_WEIGHT = 0.15;
|
|
13
|
+
const CODE_MATCH_MAX = 0.6;
|
|
14
|
+
|
|
15
|
+
function classifyComplexity(messages) {
|
|
16
|
+
if (!Array.isArray(messages)) return 0;
|
|
17
|
+
// Берем только последнее пользовательское сообщение
|
|
18
|
+
const last = [...messages].reverse().find(m => m && m.role === 'user');
|
|
19
|
+
if (!last) return 0;
|
|
20
|
+
let text = '';
|
|
21
|
+
if (typeof last.content === 'string') text = last.content;
|
|
22
|
+
else if (Array.isArray(last.content)) {
|
|
23
|
+
text = last.content.filter(c => c && c.type === 'text').map(c => c.text || '').join(' ');
|
|
24
|
+
}
|
|
25
|
+
text = (text || '').trim();
|
|
26
|
+
if (text.length === 0) return 0;
|
|
27
|
+
|
|
28
|
+
let score = 0;
|
|
29
|
+
// 1. Длина — длинные запросы сложнее (до +0.3)
|
|
30
|
+
score += Math.min(text.length / 2000, 0.3);
|
|
31
|
+
// 2. Код-признаки (до +0.6)
|
|
32
|
+
let codeMatches = (text.match(CODE_WORDS_EN) || []).length;
|
|
33
|
+
codeMatches += (text.match(CODE_WORDS_RU) || []).length;
|
|
34
|
+
if (CODE_SYMBOLS.test(text)) codeMatches += 1;
|
|
35
|
+
score += Math.min(codeMatches * CODE_MATCH_WEIGHT, CODE_MATCH_MAX);
|
|
36
|
+
// 3. Слова об исправлении/ошибках (до +0.2)
|
|
37
|
+
if (FIX_WORDS.test(text)) score += 0.2;
|
|
38
|
+
// 4. Слова о рассуждениях (до +0.2)
|
|
39
|
+
if (REASONING_WORDS.test(text)) score += 0.2;
|
|
40
|
+
// 5. Наличие блоков кода ``` (до +0.2)
|
|
41
|
+
if ((text.match(/```/g) || []).length >= 2) score += 0.2;
|
|
42
|
+
return Math.min(score, 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Поднятие тира при высокой сложности. Простые остаются на месте.
|
|
46
|
+
const UPGRADE_MAP = {
|
|
47
|
+
'tier-s': 'tier-splus', // лёгкий → мощный (ox-alpha reasoning)
|
|
48
|
+
'tier-a': 'tier-s', // лёгкий → быстрый
|
|
49
|
+
'tier-b': 'tier-s', // лёгкий → быстрый
|
|
50
|
+
};
|
|
51
|
+
const COMPLEX_THRESHOLD = 0.5;
|
|
52
|
+
|
|
53
|
+
function maybeUpgradeTier(requestedModel, complexity) {
|
|
54
|
+
if (complexity >= COMPLEX_THRESHOLD && UPGRADE_MAP[requestedModel]) {
|
|
55
|
+
return UPGRADE_MAP[requestedModel];
|
|
56
|
+
}
|
|
57
|
+
return requestedModel;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { classifyComplexity, maybeUpgradeTier, COMPLEX_THRESHOLD };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freegate",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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
|
@@ -8,7 +8,8 @@ const { loadState, initHealth, isCircuitOpen, recordSuccess, recordFailure, reco
|
|
|
8
8
|
const { checkRateLimit } = require('./lib/rateLimit');
|
|
9
9
|
const { handleDashboard } = require('./lib/dashboard');
|
|
10
10
|
const { acquire, stats: poolStats } = require('./lib/pool');
|
|
11
|
-
const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage } = require('./lib/clean');
|
|
11
|
+
const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage, hasContent } = require('./lib/clean');
|
|
12
|
+
const { classifyComplexity, maybeUpgradeTier } = require('./lib/routing');
|
|
12
13
|
const logger = require('./lib/logger');
|
|
13
14
|
|
|
14
15
|
// Load persisted state
|
|
@@ -24,7 +25,7 @@ if (stale.length > 0) {
|
|
|
24
25
|
logger.info('Cleaned stale health entries', { removed: stale });
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const cache = new LRUCache(500, 3600000);
|
|
28
|
+
const cache = new LRUCache(500, 3600000, false, true); // 4th arg: semantic normalize ON
|
|
28
29
|
require('./lib/cache')._activeCache = cache;
|
|
29
30
|
|
|
30
31
|
// Load config (with fallback so a corrupt config never crashes the server)
|
|
@@ -120,7 +121,10 @@ setTimeout(healthCheck, 1000);
|
|
|
120
121
|
// Chat completion handler
|
|
121
122
|
async function handleChatCompletion(req, res, body) {
|
|
122
123
|
const requestedModel = body.model || 'tier-splus';
|
|
123
|
-
|
|
124
|
+
// Умный роутинг: сложные задачи с лёгкого тира поднимаем на более мощный.
|
|
125
|
+
// Классифицируем ПОСЛЕ того, как определён requestedModel, ДО выбора провайдера.
|
|
126
|
+
const effectiveModel = maybeUpgradeTier(requestedModel, classifyComplexity(body.messages));
|
|
127
|
+
let targetProviderKey = MODEL_MAP[effectiveModel] || MODEL_MAP[requestedModel] || 'zai';
|
|
124
128
|
const isStreaming = body.stream === true;
|
|
125
129
|
|
|
126
130
|
// Vision detection: if the request contains images, route to a vision provider.
|
|
@@ -205,7 +209,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
// Check cache (works for both streaming and non-streaming)
|
|
208
|
-
const cached = cache.get(
|
|
212
|
+
const cached = cache.get(effectiveModel, body.messages, body.temperature);
|
|
209
213
|
if (cached) {
|
|
210
214
|
logger.request({ model: requestedModel, provider: 'cache', status: 200, cached: true });
|
|
211
215
|
recordRecent({ model: requestedModel, provider: 'cache', status: 200, latency: 0, cached: true });
|
|
@@ -384,10 +388,12 @@ async function handleChatCompletion(req, res, body) {
|
|
|
384
388
|
}
|
|
385
389
|
});
|
|
386
390
|
result.stream.on('end', () => {
|
|
387
|
-
// Cache the assembled answer for repeat prompts (only if complete
|
|
391
|
+
// Cache the assembled answer for repeat prompts (only if complete
|
|
392
|
+
// AND non-empty — empty answers must not be cached).
|
|
388
393
|
if (chunks.length > 0) {
|
|
389
394
|
const full = chunks.join('');
|
|
390
|
-
|
|
395
|
+
if (full.trim().length > 0) {
|
|
396
|
+
cache.set(effectiveModel, body.messages, body.temperature, {
|
|
391
397
|
id: 'chatcmpl-cached',
|
|
392
398
|
object: 'chat.completion',
|
|
393
399
|
created: Math.floor(Date.now() / 1000),
|
|
@@ -395,6 +401,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
395
401
|
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
396
402
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
397
403
|
});
|
|
404
|
+
}
|
|
398
405
|
}
|
|
399
406
|
});
|
|
400
407
|
result.stream.on('error', (err) => {
|
|
@@ -411,7 +418,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
411
418
|
fixReasoningMessage(result.data.choices[0].message);
|
|
412
419
|
cleanMessage(result.data.choices[0].message);
|
|
413
420
|
}
|
|
414
|
-
cache.set(
|
|
421
|
+
if (hasContent(result.data)) cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
415
422
|
recordTokens(key, result.usage);
|
|
416
423
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
417
424
|
res.end(JSON.stringify(result.data));
|
|
@@ -472,7 +479,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
472
479
|
fixReasoningMessage(result.data.choices[0].message);
|
|
473
480
|
cleanMessage(result.data.choices[0].message);
|
|
474
481
|
}
|
|
475
|
-
cache.set(
|
|
482
|
+
if (hasContent(result.data)) cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
476
483
|
recordTokens(key, result.usage);
|
|
477
484
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
478
485
|
res.end(JSON.stringify(result.data));
|