freegate 0.6.23 → 0.6.25

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.
@@ -40,6 +40,10 @@
40
40
  "enabled": false,
41
41
  "intervalHours": 12
42
42
  },
43
+ "diagMonitor": {
44
+ "enabled": true,
45
+ "intervalMs": 1800000
46
+ },
43
47
  "providers": {
44
48
  "or-nemotron-550b": {
45
49
  "endpoint": "https://openrouter.ai/api/v1/chat/completions",
package/lib/cache.js CHANGED
@@ -22,7 +22,7 @@ class LRUCache {
22
22
  if (!skipLoad) this.load();
23
23
  }
24
24
 
25
- _key(model, messages, temperature) {
25
+ _key(model, messages, temperature, tools) {
26
26
  let raw;
27
27
  if (this.useNormalize) {
28
28
  const norm = normalizeMessages(messages);
@@ -34,6 +34,9 @@ class LRUCache {
34
34
  } else {
35
35
  raw = `${model}|${JSON.stringify(messages)}|${temperature || 0}`;
36
36
  }
37
+ // Инструменты (tools/tool_choice) влияют на ответ (tool_calls vs текст) —
38
+ // без этого tool-запрос попадал на текстовый кэш и агент «останавливался».
39
+ if (tools) raw += `|tools=${JSON.stringify(tools)}`;
37
40
  return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
38
41
  }
39
42
 
@@ -77,8 +80,8 @@ if (now - e.created > this.ttl) continue;
77
80
  } catch {}
78
81
  }
79
82
 
80
- get(model, messages, temperature) {
81
- const key = this._key(model, messages, temperature);
83
+ get(model, messages, temperature, tools) {
84
+ const key = this._key(model, messages, temperature, tools);
82
85
  const entry = this.cache.get(key);
83
86
 
84
87
  if (!entry) {
@@ -141,8 +144,8 @@ if (now - e.created > this.ttl) continue;
141
144
  return { value: entry.value, similarity: Math.round(bestSim * 1000) / 1000 };
142
145
  }
143
146
 
144
- set(model, messages, temperature, value, providerKey) {
145
- const key = this._key(model, messages, temperature);
147
+ set(model, messages, temperature, value, providerKey, tools) {
148
+ const key = this._key(model, messages, temperature, tools);
146
149
 
147
150
  // Delete if exists (to update order)
148
151
  if (this.cache.has(key)) this.cache.delete(key);
package/lib/clean.js CHANGED
@@ -62,6 +62,7 @@ function hasContent(data) {
62
62
  if (!msg) return false;
63
63
  const content = typeof msg.content === 'string' ? msg.content.trim() : '';
64
64
  const reasoning = typeof msg.reasoning === 'string' ? msg.reasoning.trim() : '';
65
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) return true;
65
66
  return content.length > 0 || reasoning.length > 0;
66
67
  }
67
68
 
@@ -75,6 +76,10 @@ function isTooShort(data, askedText) {
75
76
  const msg = data.choices[0]?.message;
76
77
  const content = typeof msg?.content === 'string' ? msg.content.trim() : '';
77
78
  const reasoning = typeof msg?.reasoning === 'string' ? msg.reasoning.trim() : '';
79
+ // Ответ через tool-вызов (агент вызвал инструмент) НЕ пустой — это осмысленный
80
+ // ход, а не «мусор». Без этой проверки прокси считал tool_calls-ответ пустым,
81
+ // отбрасывал его и агент «останавливался», не получив инструмента.
82
+ if (Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0) return false;
78
83
  const ask = typeof askedText === 'string' ? askedText.trim().length : 0;
79
84
  const threshold = ask > 0 && ask < 40 ? Math.max(1, Math.min(MIN_ANSWER_LEN, Math.ceil(ask / 8))) : MIN_ANSWER_LEN;
80
85
  return (content + reasoning).length < threshold;
package/lib/compactor.js CHANGED
@@ -17,12 +17,14 @@ const KEEP_RECENT_TOKENS = 30000; // tokens — recent messages kept untouched (
17
17
  const MAX_COMPACT_THRESHOLD = 100000; // est tokens — cap for window-aware thresholds (≈200k real,
18
18
  // sits safely inside even 512k/1M windows without compacting too early)
19
19
  const SUMMARY_MAX_TOKENS = 2000; // tokens — summary length cap (detailed enough to not lose the work)
20
- const CHARS_PER_TOKEN = 1.0; // heuristic chars→tokens. Прежнее 1.5 ДВАЖДЫ завышало
21
- // оценку (реально ~0.75-0.8 chars/token) → компакции
22
- // срабатывали почти на каждый второй запрос (53%),
23
- // каждый доп. LLM-вызов = медленно + риск «замирания».
24
- // 1.0 всё ещё с запасом, но почти вдвое меньше лишних
25
- // компакций. keep-recent и оконные пороги не меняем.
20
+ const CHARS_PER_TOKEN = 2.0; // heuristic chars→tokens. 3.0 СЛИШКОМ занижал оценку:
21
+ // window-aware фильтр не отсеивал groq (окно 131k) при
22
+ // реальном контексте 140k+ groq принимал слишком большой
23
+ // запрос и МОЛЧАЛ («no first token») каскад → зависание
24
+ // на минуты. 2.0: диалог 300k+ символов оценивается >131k
25
+ // (groq отсеивается) и компактится (>60k), но обычный
26
+ // рабочий диалог агента компактится реже, чем при 1.5.
27
+ // Баланс: не занижаем для окна, не завышаем для компакций.
26
28
 
27
29
  // Reliability-ordered long-context summarizers. Порядок важен: getSummary идёт
28
30
  // по списку и ждёт ПЕРВЫЙ отвечающий (до 30с). Медленные/непроверенные в начале
@@ -0,0 +1,92 @@
1
+ // Снятие периодических срезов метрик для сравнения «до/после».
2
+ // Каждые INTERVAL_MS пишет точку {ts, successRate, requests, errors, compactCount,
3
+ // poolActive, poolTotal, errorsByClass, version} в diag_history.json. Данные
4
+ // переживают перезапуск (пишутся на диск) — можно строить тренд по времени.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { getStats, getHealth, getContextStats } = require('./health');
8
+
9
+ const HISTORY_PATH = process.env.DIAG_HISTORY_PATH || path.join(__dirname, '..', 'diag_history.json');
10
+ const INTERVAL_MS = 30 * 60 * 1000; // 30 мин
11
+ const MAX_POINTS = 2000;
12
+
13
+ function classifyErrors(errors) {
14
+ // Грубая классификация по статусу провайдера в health (без сетевых вызовов):
15
+ // 429 → limit; 401/402/403 → auth; прочее/нет данных → invalid.
16
+ const health = getHealth();
17
+ const out = { limit: 0, auth: 0, invalid: 0, timeout: 0 };
18
+ for (const [key, cnt] of Object.entries(errors || {})) {
19
+ const h = health[key] || {};
20
+ if (h.statusCode === 429) out.limit += cnt;
21
+ else if ([401, 402, 403].includes(h.statusCode)) out.auth += cnt;
22
+ else if (h.statusCode === 0) out.timeout += cnt;
23
+ else out.invalid += cnt;
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function snapshot() {
29
+ const stats = getStats() || {};
30
+ const ctx = getContextStats() || {};
31
+ const health = getHealth() || {};
32
+ const errors = stats.errors || {};
33
+ const today = todayStat(stats);
34
+ const active = Object.values(health).filter((h) => h.status === 'up' || h.status === 'ratelimited').length;
35
+ return {
36
+ ts: Date.now(),
37
+ version: process.env.FREEGATE_VERSION || currentVersion(),
38
+ requests: today.requests,
39
+ success: today.success,
40
+ failed: today.failed,
41
+ successRate: today.successRate,
42
+ compactCount: ctx.compactCount || 0,
43
+ upgradedCount: ctx.upgradedCount || 0,
44
+ poolActive: active,
45
+ poolTotal: Object.keys(health).length,
46
+ errorsByClass: classifyErrors(errors),
47
+ };
48
+ }
49
+
50
+ function todayStat(stats) {
51
+ // Дневные счётчики из stats.today (вычисляется сервером) с fallback на общие.
52
+ const day = stats.today || {};
53
+ const total = day.requests || 0;
54
+ return {
55
+ requests: total,
56
+ success: day.success || 0,
57
+ failed: day.failed || 0,
58
+ successRate: total > 0 ? Math.round(((day.success || 0) / total) * 100) : null,
59
+ };
60
+ }
61
+
62
+ function currentVersion() {
63
+ try { return require('../package.json').version; } catch { return 'dev'; }
64
+ }
65
+
66
+ function loadHistory() {
67
+ try { return JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8')); }
68
+ catch { return []; }
69
+ }
70
+
71
+ function recordSnapshot() {
72
+ const hist = loadHistory();
73
+ hist.push(snapshot());
74
+ while (hist.length > MAX_POINTS) hist.shift();
75
+ try {
76
+ fs.writeFileSync(HISTORY_PATH, JSON.stringify(hist, null, 2));
77
+ } catch {}
78
+ return hist.length;
79
+ }
80
+
81
+ let _timer = null;
82
+ function startMonitor(intervalMs = INTERVAL_MS) {
83
+ stopMonitor();
84
+ recordSnapshot(); // сразу фиксируем стартовую точку
85
+ _timer = setInterval(recordSnapshot, intervalMs);
86
+ }
87
+
88
+ function stopMonitor() {
89
+ if (_timer) { clearInterval(_timer); _timer = null; }
90
+ }
91
+
92
+ module.exports = { snapshot, recordSnapshot, loadHistory, startMonitor, stopMonitor, INTERVAL_MS };
package/lib/providers.js CHANGED
@@ -132,7 +132,7 @@ const MODEL_MAP = {
132
132
  'gemini-3.6-flash': 'gemini-flash',
133
133
  'codestral-latest': 'mistral-codestral', 'mistral-small-latest': 'mistral-small',
134
134
  'mistral-medium-latest': 'mistral-small', 'mistral-large-latest': 'mistral-small',
135
- 'tier-splus': 'or-minimax-m3-free', 'tier-s': 'mistral-codestral',
135
+ 'tier-splus': 'or-dots-3', 'tier-s': 'mistral-codestral',
136
136
  'tier-a': 'mistral-small', 'tier-b': 'groq-qwen',
137
137
  'tier-xl': 'or-dots-3', 'tier-l': 'or-minimax-m3-free',
138
138
  'openrouter-hermes': 'openrouter-hermes',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freegate",
3
- "version": "0.6.23",
3
+ "version": "0.6.25",
4
4
  "description": "Free multi-provider LLM gateway with automatic failover. One OpenAI-compatible endpoint routes to many free models (Groq, Mistral, Gemini, NIM, OpenRouter, ZAI, Cerebras, DeepSeek and more). Never pay for LLMs.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -8,7 +8,9 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
- "test": "node --test --test-concurrency=1 test/proxy.test.js test/clean.test.js"
11
+ "test": "node --test --test-concurrency=1 test/proxy.test.js test/clean.test.js",
12
+ "set-model-version": "node scripts/set-model-version.js",
13
+ "release": "npm test && npm run set-model-version && npm publish"
12
14
  },
13
15
  "engines": {
14
16
  "node": ">=18"
package/providers.json CHANGED
@@ -646,7 +646,7 @@
646
646
  "gemini-gemma-4-26b-a4b-it": {
647
647
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
648
648
  "model": "gemma-4-26b-a4b-it",
649
- "priority": 5,
649
+ "priority": 6,
650
650
  "dailyLimit": 1500,
651
651
  "keyHint": "gemini → Keys (автодобавлено, general)",
652
652
  "envVar": "PROVIDER_GEMINI_APIKEY",
@@ -676,7 +676,7 @@
676
676
  "gemini-gemini-3-1-flash-lite-preview": {
677
677
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
678
678
  "model": "gemini-3.1-flash-lite-preview",
679
- "priority": 6,
679
+ "priority": 3,
680
680
  "dailyLimit": 1500,
681
681
  "keyHint": "gemini → Keys (автодобавлено, general)",
682
682
  "envVar": "PROVIDER_GEMINI_APIKEY",
@@ -686,7 +686,7 @@
686
686
  "gemini-gemini-3-5-flash": {
687
687
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
688
688
  "model": "gemini-3.5-flash",
689
- "priority": 3,
689
+ "priority": 7,
690
690
  "dailyLimit": 1500,
691
691
  "keyHint": "gemini → Keys (автодобавлено, general)",
692
692
  "envVar": "PROVIDER_GEMINI_APIKEY",
@@ -706,7 +706,7 @@
706
706
  "gemini-gemini-3-1-flash-lite": {
707
707
  "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
708
708
  "model": "gemini-3.1-flash-lite",
709
- "priority": 7,
709
+ "priority": 5,
710
710
  "dailyLimit": 1500,
711
711
  "keyHint": "gemini → Keys (автодобавлено, general)",
712
712
  "envVar": "PROVIDER_GEMINI_APIKEY",
package/server.js CHANGED
@@ -162,6 +162,8 @@ const MODEL_MANAGER_CONFIG = Object.assign(
162
162
  );
163
163
  const autoUpdate = require('./lib/autoupdate');
164
164
  const AUTO_UPDATE = (config.autoUpdate && typeof config.autoUpdate === 'object') ? config.autoUpdate : {};
165
+ const diagMonitor = require('./lib/diagmonitor');
166
+ const DIAG_MONITOR = (config.diagMonitor && typeof config.diagMonitor === 'object') ? config.diagMonitor : { enabled: true, intervalMs: 30 * 60 * 1000 };
165
167
  const modelManager = new ModelManager({
166
168
  dbPath: path.join(__dirname, 'models-db.json'),
167
169
  catalogPath: path.join(__dirname, 'providers.json'),
@@ -557,8 +559,11 @@ async function handleChatCompletion(req, res, body) {
557
559
  }
558
560
  }
559
561
 
560
- // Check cache (works for both streaming and non-streaming)
561
- const cached = cache.get(effectiveModel, body.messages, body.temperature);
562
+ // Tool-запросы (агент вызывает инструмент) НЕ кэшируем: ответ зависит от tool_calls,
563
+ // а кэш по messages может отдать текстовый ответ вместо инструмента — агент
564
+ // «останавливается», не получив tool. Идём всегда к провайдеру.
565
+ const hasTools = !!(body.tools || body.tool_choice);
566
+ const cached = hasTools ? null : cache.get(effectiveModel, body.messages, body.temperature, body.tools || body.tool_choice);
562
567
  if (cached) {
563
568
  logger.request({ model: requestedModel, provider: 'cache', status: 200, cached: true });
564
569
  recordRecent({ model: requestedModel, provider: 'cache', status: 200, latency: 0, cached: true });
@@ -569,7 +574,7 @@ async function handleChatCompletion(req, res, body) {
569
574
  }
570
575
 
571
576
  // Semantic cache: same intent, rephrased wording → replay without a new LLM call.
572
- if (SEMCACHE_CONFIG.enabled) {
577
+ if (SEMCACHE_CONFIG.enabled && !hasTools) {
573
578
  const semantic = cache.getSemantic(effectiveModel, body.messages, body.temperature, SEMCACHE_CONFIG.minSimilarity);
574
579
  if (semantic) {
575
580
  logger.request({ model: requestedModel, provider: 'semcache', status: 200, cached: true });
@@ -905,7 +910,7 @@ async function handleChatCompletion(req, res, body) {
905
910
  model: provider.model,
906
911
  choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
907
912
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
908
- }, key);
913
+ }, key, body.tools || body.tool_choice);
909
914
  }
910
915
  commit(200);
911
916
  res.end();
@@ -1015,7 +1020,7 @@ async function handleChatCompletion(req, res, body) {
1015
1020
  model: provider.model,
1016
1021
  choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
1017
1022
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
1018
- }, key);
1023
+ }, key, body.tools || body.tool_choice);
1019
1024
  }
1020
1025
  commit(200);
1021
1026
  res.end();
@@ -1064,7 +1069,7 @@ async function handleChatCompletion(req, res, body) {
1064
1069
  }
1065
1070
  }
1066
1071
  commit(200);
1067
- cache.set(effectiveModel, body.messages, body.temperature, result.data, key);
1072
+ cache.set(effectiveModel, body.messages, body.temperature, result.data, key, body.tools || body.tool_choice);
1068
1073
  recordTokens(key, result.usage);
1069
1074
  res.writeHead(200, { 'Content-Type': 'application/json' });
1070
1075
  res.end(JSON.stringify(result.data));
@@ -1161,7 +1166,7 @@ if (isTooShort(result.data, lastUserText(body.messages))) {
1161
1166
  measure.real = (result.data.usage && result.data.usage.prompt_tokens) ? result.data.usage.prompt_tokens : measure.sentTokens || 0;
1162
1167
  measure.win = PROVIDERS[key]?.context_window || 0;
1163
1168
  commit(200);
1164
- cache.set(effectiveModel, body.messages, body.temperature, result.data, key);
1169
+ cache.set(effectiveModel, body.messages, body.temperature, result.data, key, body.tools || body.tool_choice);
1165
1170
  recordTokens(key, result.usage);
1166
1171
  res.writeHead(200, { 'Content-Type': 'application/json' });
1167
1172
  res.end(JSON.stringify(result.data));
@@ -1219,7 +1224,7 @@ if (isTooShort(result.data, lastUserText(body.messages))) {
1219
1224
  model: provider.model,
1220
1225
  choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
1221
1226
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
1222
- }, key);
1227
+ }, key, body.tools || body.tool_choice);
1223
1228
  }
1224
1229
  commit(200);
1225
1230
  res.end();
@@ -1756,10 +1761,16 @@ server.listen(PORT, process.env.HOST || '127.0.0.1', () => {
1756
1761
  autoUpdate.startAutoUpdate({ root: __dirname, log: (m) => logger.info(m) });
1757
1762
  logger.info('AutoUpdate enabled', { intervalMs: AUTO_UPDATE.intervalMs });
1758
1763
  }
1764
+ // Мониторинг метрик: снапшот каждые 30 мин → можно сравнить «до/после» фиксов.
1765
+ if (DIAG_MONITOR.enabled) {
1766
+ diagMonitor.startMonitor(DIAG_MONITOR.intervalMs);
1767
+ logger.info('DiagMonitor started', { intervalMs: DIAG_MONITOR.intervalMs });
1768
+ }
1759
1769
  });
1760
1770
 
1761
1771
  const _shutdown = () => {
1762
1772
  if (memStore) { memStore.stopTimer(); memStore.save(); }
1773
+ diagMonitor.stopMonitor();
1763
1774
  require('./lib/health').saveState();
1764
1775
  cache.persist();
1765
1776
  try { modelManager.stop(); } catch {}