freegate 0.6.7 → 0.6.8
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/normalize.js +74 -0
- package/lib/providers.js +1 -1
- package/lib/routing.js +60 -0
- package/package.json +1 -1
- package/server.js +10 -6
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/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.8",
|
|
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,6 +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 } = 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 });
|
|
@@ -387,7 +391,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
387
391
|
// Cache the assembled answer for repeat prompts (only if complete)
|
|
388
392
|
if (chunks.length > 0) {
|
|
389
393
|
const full = chunks.join('');
|
|
390
|
-
cache.set(
|
|
394
|
+
cache.set(effectiveModel, body.messages, body.temperature, {
|
|
391
395
|
id: 'chatcmpl-cached',
|
|
392
396
|
object: 'chat.completion',
|
|
393
397
|
created: Math.floor(Date.now() / 1000),
|
|
@@ -411,7 +415,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
411
415
|
fixReasoningMessage(result.data.choices[0].message);
|
|
412
416
|
cleanMessage(result.data.choices[0].message);
|
|
413
417
|
}
|
|
414
|
-
cache.set(
|
|
418
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
415
419
|
recordTokens(key, result.usage);
|
|
416
420
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
417
421
|
res.end(JSON.stringify(result.data));
|
|
@@ -472,7 +476,7 @@ async function handleChatCompletion(req, res, body) {
|
|
|
472
476
|
fixReasoningMessage(result.data.choices[0].message);
|
|
473
477
|
cleanMessage(result.data.choices[0].message);
|
|
474
478
|
}
|
|
475
|
-
cache.set(
|
|
479
|
+
cache.set(effectiveModel, body.messages, body.temperature, result.data);
|
|
476
480
|
recordTokens(key, result.usage);
|
|
477
481
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
478
482
|
res.end(JSON.stringify(result.data));
|