freegate 0.6.9 → 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/normalize.js CHANGED
@@ -45,21 +45,36 @@ function looksLikeCode(messages) {
45
45
 
46
46
  function normalizeMessages(messages) {
47
47
  if (!Array.isArray(messages)) return '';
48
- const userOnly = messages.filter(m => m && m.role === 'user');
48
+ const dialog = messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
49
49
  if (looksLikeCode(messages)) {
50
50
  // Код: точное совпадение, чтобы операторы не схлопывались в один ключ.
51
- return JSON.stringify(userOnly);
51
+ return JSON.stringify(dialog);
52
52
  }
53
- const userTexts = userOnly
53
+ // Включаем ВЕСЬ диалог (user + assistant), но НЕ system-промпт: короткие
54
+ // команды («продолжай») из разных диалогов с разным контекстом не должны
55
+ // схлопываться в один кэш-ключ и возвращать чужой ответ.
56
+ const texts = dialog
54
57
  .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
+ const role = m.role === 'user' ? 'u' : 'a';
59
+ let body = '';
60
+ if (typeof m.content === 'string') body = m.content;
61
+ else if (Array.isArray(m.content)) {
62
+ body = m.content.filter(c => c && c.type === 'text').map(c => c.text || '').join(' ');
58
63
  }
64
+ return role + ':' + body;
65
+ })
66
+ .join('\n');
67
+ // Нет извлекаемого текста (только картинки/инструменты) → пусто, чтобы cache
68
+ // упал на точное совпадение (иначе image-only запросы схлопнутся в один ключ).
69
+ const rawText = dialog
70
+ .map(m => {
71
+ if (typeof m.content === 'string') return m.content;
72
+ if (Array.isArray(m.content)) return m.content.filter(c => c && c.type === 'text').map(c => c.text || '').join(' ');
59
73
  return '';
60
74
  })
61
75
  .join('\n');
62
- return normalizeText(userTexts);
76
+ if (!rawText.trim()) return '';
77
+ return normalizeText(texts);
63
78
  }
64
79
 
65
80
  function normalizeText(text) {
package/lib/routing.js CHANGED
@@ -57,4 +57,24 @@ function maybeUpgradeTier(requestedModel, complexity) {
57
57
  return requestedModel;
58
58
  }
59
59
 
60
- module.exports = { classifyComplexity, maybeUpgradeTier, COMPLEX_THRESHOLD };
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.9",
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/providers.json CHANGED
@@ -304,5 +304,75 @@
304
304
  "envVar": "PROVIDER_OPENROUTER_APIKEY",
305
305
  "free": true,
306
306
  "category": "vision"
307
+ },
308
+ "hf-Qwen3-8-2-4T-A95B": {
309
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
310
+ "model": "Qwen/Qwen3.8-2.4T-A95B",
311
+ "priority": 20,
312
+ "dailyLimit": 200,
313
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
314
+ "envVar": "PROVIDER_HF_APIKEY",
315
+ "free": true,
316
+ "category": "general"
317
+ },
318
+ "hf-GLM-5-2": {
319
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
320
+ "model": "zai-org/GLM-5.2",
321
+ "priority": 20,
322
+ "dailyLimit": 200,
323
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
324
+ "envVar": "PROVIDER_HF_APIKEY",
325
+ "free": true,
326
+ "category": "general"
327
+ },
328
+ "hf-gemma-4-31B-it": {
329
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
330
+ "model": "google/gemma-4-31B-it",
331
+ "priority": 20,
332
+ "dailyLimit": 200,
333
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
334
+ "envVar": "PROVIDER_HF_APIKEY",
335
+ "free": true,
336
+ "category": "general"
337
+ },
338
+ "hf-DeepSeek-V4-Pro": {
339
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
340
+ "model": "deepseek-ai/DeepSeek-V4-Pro",
341
+ "priority": 20,
342
+ "dailyLimit": 200,
343
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
344
+ "envVar": "PROVIDER_HF_APIKEY",
345
+ "free": true,
346
+ "category": "general"
347
+ },
348
+ "hf-gemma-4-26B-A4B-it": {
349
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
350
+ "model": "google/gemma-4-26B-A4B-it",
351
+ "priority": 20,
352
+ "dailyLimit": 200,
353
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
354
+ "envVar": "PROVIDER_HF_APIKEY",
355
+ "free": true,
356
+ "category": "general"
357
+ },
358
+ "hf-Llama-3-1-8B-Instruct": {
359
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
360
+ "model": "meta-llama/Llama-3.1-8B-Instruct",
361
+ "priority": 20,
362
+ "dailyLimit": 200,
363
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
364
+ "envVar": "PROVIDER_HF_APIKEY",
365
+ "free": true,
366
+ "category": "general"
367
+ },
368
+ "hf-DeepSeek-V4-Flash": {
369
+ "endpoint": "https://router.huggingface.co/v1/chat/completions",
370
+ "model": "deepseek-ai/DeepSeek-V4-Flash",
371
+ "priority": 20,
372
+ "dailyLimit": 200,
373
+ "keyHint": "huggingface.co → Settings → Tokens (автодобавлено, general)",
374
+ "envVar": "PROVIDER_HF_APIKEY",
375
+ "free": true,
376
+ "category": "general"
307
377
  }
308
378
  }
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
- for (const [key, provider] of Object.entries(PROVIDERS)) {
112
- if (!provider.enabled) continue;
113
- const interval = healthIntervals[key];
114
- if (interval && interval.nextCheck > now) continue;
115
- await checkProvider(key, provider);
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
- const effectiveModel = maybeUpgradeTier(requestedModel, classifyComplexity(body.messages));
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
- delete result.data.nvext;
417
- if (result.data.choices?.[0]) {
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
- if (result.data.choices?.[0]) {
479
- fixReasoningMessage(result.data.choices[0].message);
480
- cleanMessage(result.data.choices[0].message);
481
- }
482
- if (hasContent(result.data)) cache.set(effectiveModel, body.messages, body.temperature, 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;