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 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
- const raw = `${model}|${JSON.stringify(messages)}|${temperature || 0}`;
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
 
@@ -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.7",
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
- let targetProviderKey = MODEL_MAP[requestedModel] || 'zai';
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(requestedModel, body.messages, body.temperature);
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(requestedModel, body.messages, body.temperature, {
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(requestedModel, body.messages, body.temperature, result.data);
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(requestedModel, body.messages, body.temperature, result.data);
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));