freegate 0.6.10 → 0.6.12

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/clean.js CHANGED
@@ -65,4 +65,14 @@ function hasContent(data) {
65
65
  return content.length > 0 || reasoning.length > 0;
66
66
  }
67
67
 
68
- module.exports = { stripThink, cleanMessage, cleanDelta, fixReasoningMessage, hasContent };
68
+ // Ответ короче MIN_ANSWER_LEN символов считается мусором (обрыв/один токен).
69
+ const MIN_ANSWER_LEN = 5;
70
+ function isTooShort(data) {
71
+ if (!data || !Array.isArray(data.choices)) return true;
72
+ const msg = data.choices[0]?.message;
73
+ const content = typeof msg?.content === 'string' ? msg.content.trim() : '';
74
+ const reasoning = typeof msg?.reasoning === 'string' ? msg.reasoning.trim() : '';
75
+ return (content + reasoning).length < MIN_ANSWER_LEN;
76
+ }
77
+
78
+ module.exports = { stripThink, cleanMessage, cleanDelta, fixReasoningMessage, hasContent, isTooShort, MIN_ANSWER_LEN };
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.10",
3
+ "version": "0.6.12",
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
@@ -374,5 +374,25 @@
374
374
  "envVar": "PROVIDER_HF_APIKEY",
375
375
  "free": true,
376
376
  "category": "general"
377
+ },
378
+ "or-minimax-m3-free": {
379
+ "endpoint": "https://openrouter.ai/api/v1/chat/completions",
380
+ "model": "minimax/minimax-m3:free",
381
+ "priority": 20,
382
+ "dailyLimit": 50,
383
+ "keyHint": "openrouter.ai → Keys (автодобавлено, vision)",
384
+ "envVar": "PROVIDER_OPENROUTER_APIKEY",
385
+ "free": true,
386
+ "category": "vision"
387
+ },
388
+ "or-minimax-m2-7-free": {
389
+ "endpoint": "https://openrouter.ai/api/v1/chat/completions",
390
+ "model": "minimax/minimax-m2.7:free",
391
+ "priority": 20,
392
+ "dailyLimit": 50,
393
+ "keyHint": "openrouter.ai → Keys (автодобавлено, reasoning)",
394
+ "envVar": "PROVIDER_OPENROUTER_APIKEY",
395
+ "free": true,
396
+ "category": "reasoning"
377
397
  }
378
398
  }
package/server.js CHANGED
@@ -8,8 +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, hasContent } = require('./lib/clean');
12
- const { classifyComplexity, maybeUpgradeTier } = require('./lib/routing');
11
+ const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage, isTooShort, MIN_ANSWER_LEN } = require('./lib/clean');
12
+ const { classifyComplexity, maybeUpgradeTier, classifyVisionComplexity } = require('./lib/routing');
13
13
  const logger = require('./lib/logger');
14
14
 
15
15
  // Load persisted state
@@ -106,24 +106,39 @@ async function checkProvider(key, provider) {
106
106
  }
107
107
  }
108
108
 
109
+ const PROBE_CAP = 8;
110
+
109
111
  async function healthCheck() {
110
112
  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);
113
+ // Параллельный пробинг с лимитом: пакетами по PROBE_CAP, чтобы не создавать
114
+ // десятки одновременных fetch-запросов при 30+ провайдерах.
115
+ const due = Object.entries(PROVIDERS)
116
+ .filter(([key, provider]) => provider.enabled && !(healthIntervals[key] && healthIntervals[key].nextCheck > now));
117
+ for (let i = 0; i < due.length; i += PROBE_CAP) {
118
+ const batch = due.slice(i, i + PROBE_CAP);
119
+ await Promise.allSettled(batch.map(([key, provider]) => checkProvider(key, provider)));
116
120
  }
117
121
  }
118
122
  setInterval(healthCheck, 30000);
119
123
  setTimeout(healthCheck, 1000);
120
124
 
125
+ // Извлекает все значения content из SSE-чанка. Возвращает true, если есть
126
+ // хотя бы одно непустое (реальный токен, а не пустая дельта).
127
+ function chunkHasToken(str) {
128
+ const re = /"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
129
+ let m;
130
+ while ((m = re.exec(str)) !== null) {
131
+ if (m[1].trim().length > 0) return true;
132
+ }
133
+ return false;
134
+ }
135
+
121
136
  // Chat completion handler
122
137
  async function handleChatCompletion(req, res, body) {
123
138
  const requestedModel = body.model || 'tier-splus';
124
139
  // Умный роутинг: сложные задачи с лёгкого тира поднимаем на более мощный.
125
140
  // Классифицируем ПОСЛЕ того, как определён requestedModel, ДО выбора провайдера.
126
- const effectiveModel = maybeUpgradeTier(requestedModel, classifyComplexity(body.messages));
141
+ let effectiveModel = maybeUpgradeTier(requestedModel, classifyComplexity(body.messages));
127
142
  let targetProviderKey = MODEL_MAP[effectiveModel] || MODEL_MAP[requestedModel] || 'zai';
128
143
  const isStreaming = body.stream === true;
129
144
 
@@ -191,6 +206,11 @@ async function handleChatCompletion(req, res, body) {
191
206
  const cleaned = stripThink(extracted, true);
192
207
  logger.info('Vision pipeline: скриншот распознан', { chars: cleaned.length });
193
208
  if (cleaned) {
209
+ // Умный vision-роутинг: скриншот с кодом/ошибкой поднимает тир.
210
+ const vc = classifyVisionComplexity(cleaned);
211
+ if (vc > 0) effectiveModel = maybeUpgradeTier(effectiveModel, vc);
212
+ // Пересчитываем target-провайдера — vision-апгрейд мог сменить тир.
213
+ targetProviderKey = MODEL_MAP[effectiveModel] || MODEL_MAP[requestedModel] || 'zai';
194
214
  // Replace image content with the extracted text as context,
195
215
  // so the coding/general model (not vision) answers the question.
196
216
  const userMsgs = Array.isArray(body.messages) ? body.messages : [];
@@ -346,53 +366,157 @@ async function handleChatCompletion(req, res, body) {
346
366
  // provider speed. Huge latency would poison the weighted selection.
347
367
  getHealth()[key].latency = Math.min(result.latency || 0, 60000);
348
368
  getHealth()[key].lastCheck = Date.now();
349
- recordSuccess(key);
350
- recordRequest(key, true);
351
- logger.request({ model: requestedModel, provider: key, status: 200, latency: result.latency, stream: isStreaming });
352
- recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
353
- recordSelection(key, provider.model, requestedModel);
369
+
370
+ // For non-stream, verify the response isn't empty BEFORE recording success.
371
+ if (!isStreaming && result.data) {
372
+ delete result.data.nvext;
373
+ if (result.data.choices?.[0]) {
374
+ fixReasoningMessage(result.data.choices[0].message);
375
+ cleanMessage(result.data.choices[0].message);
376
+ }
377
+ if (isTooShort(result.data)) {
378
+ // Пустой/мусорный ответ (провайдер-глитч) НЕ считается успехом — пробуем следующего.
379
+ const msg = key + ': empty or too short response';
380
+ errors.push(msg);
381
+ recordFailure(key, 0);
382
+ recordRequest(key, false, msg);
383
+ recordRecent({ model: requestedModel, provider: key, status: 204, latency: result.latency, cached: false });
384
+ logger.warn('Empty or too-short response, trying next provider', { key });
385
+ continue;
386
+ }
387
+ }
354
388
 
355
389
  if (isStreaming && result.stream) {
356
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
357
- const { Transform } = require('stream');
358
- // Accumulate content deltas so we can cache the final answer for
359
- // identical repeat prompts (opencode always streams).
360
390
  const chunks = [];
361
- const cleaner = new Transform({
362
- transform(chunk, encoding, callback) {
363
- const str = chunk.toString();
364
- // Collect SSE JSON payloads for caching (strip think blocks)
365
- const lines = str.split('\n');
366
- for (const line of lines) {
367
- const m = line.match(/^data: (.+)$/);
368
- if (!m || m[1].trim() === '[DONE]') continue;
369
- try {
370
- const obj = JSON.parse(m[1]);
371
- const delta = obj.choices?.[0]?.delta?.content;
372
- if (typeof delta === 'string') chunks.push(stripThink(delta, false));
373
- } catch {}
391
+
392
+ // Очистка SSE-строки: убрать nvext, logprobs, think-блоки из дельт.
393
+ const cleanStr = (str) => str.replace(/^data: (.+)$/gm, (match, jsonStr) => {
394
+ if (jsonStr.trim() === '[DONE]') return match;
395
+ try {
396
+ const obj = JSON.parse(jsonStr);
397
+ delete obj.nvext;
398
+ if (obj.choices?.[0]) {
399
+ delete obj.choices[0].logprobs;
400
+ cleanDelta(obj.choices[0].delta);
374
401
  }
375
- const cleaned = str.replace(/^data: (.+)$/gm, (match, jsonStr) => {
376
- if (jsonStr.trim() === '[DONE]') return match;
377
- try {
378
- const obj = JSON.parse(jsonStr);
379
- delete obj.nvext;
380
- if (obj.choices?.[0]) {
381
- delete obj.choices[0].logprobs;
382
- cleanDelta(obj.choices[0].delta);
383
- }
384
- return 'data: ' + JSON.stringify(obj);
385
- } catch { return match; }
386
- });
387
- callback(null, cleaned);
402
+ return 'data: ' + JSON.stringify(obj);
403
+ } catch { return match; }
404
+ });
405
+
406
+ // Сбор контент-токенов для кэша (strip think).
407
+ const collect = (str) => {
408
+ const lines = str.split('\n');
409
+ for (const line of lines) {
410
+ const m = line.match(/^data: (.+)$/);
411
+ if (!m || m[1].trim() === '[DONE]') continue;
412
+ try {
413
+ const obj = JSON.parse(m[1]);
414
+ const delta = obj.choices?.[0]?.delta?.content;
415
+ if (typeof delta === 'string') chunks.push(stripThink(delta, false));
416
+ } catch {}
388
417
  }
418
+ };
419
+
420
+ // Reasoning-модели (ox-alpha) думают 10с+ до первого токена — стримим сразу.
421
+ // Успех записываем ДО любого токена намеренно: fallback по таймауту 5с
422
+ // нанёс бы лишний дабл-счёт, если бы success фиксировался после первого токена.
423
+ if (provider.reasoning) {
424
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
425
+ recordSuccess(key);
426
+ recordRequest(key, true);
427
+ logger.request({ model: requestedModel, provider: key, status: 200, latency: result.latency, stream: isStreaming });
428
+ recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
429
+ recordSelection(key, provider.model, requestedModel);
430
+ const { Transform } = require('stream');
431
+ const cleaner = new Transform({
432
+ transform(chunk, encoding, callback) {
433
+ const str = chunk.toString();
434
+ collect(str);
435
+ callback(null, cleanStr(str));
436
+ }
437
+ });
438
+ result.stream.on('end', () => {
439
+ const full = chunks.join('');
440
+ if (full.trim().length >= MIN_ANSWER_LEN) {
441
+ cache.set(effectiveModel, body.messages, body.temperature, {
442
+ id: 'chatcmpl-cached',
443
+ object: 'chat.completion',
444
+ created: Math.floor(Date.now() / 1000),
445
+ model: provider.model,
446
+ choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
447
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
448
+ });
449
+ }
450
+ res.end();
451
+ });
452
+ result.stream.on('error', (err) => { logger.error('Stream error', { key, error: err.message }); res.end(); });
453
+ result.stream.pipe(cleaner).pipe(res);
454
+ return;
455
+ }
456
+
457
+ // Обычные модели: буферизуем до первого токена (макс 5 сек).
458
+ // Заголовки не пишем сразу — если токена нет за 5 сек, fallback.
459
+ const rawBuf = [];
460
+ const firstToken = new Promise((resolve) => {
461
+ let done = false;
462
+ const timer = setTimeout(() => { if (!done) { done = true; resolve(false); } }, 5000);
463
+ const finish = (ok) => { if (!done) { done = true; clearTimeout(timer); resolve(ok); } };
464
+ result.stream.on('data', (chunk) => {
465
+ const str = chunk.toString();
466
+ rawBuf.push(str);
467
+ collect(str);
468
+ // Первый контент-токен: хотя бы одно непустое `"content":"..."` в чанке.
469
+ if (chunkHasToken(str)) {
470
+ finish(true);
471
+ }
472
+ });
473
+ result.stream.once('end', () => finish(false));
474
+ result.stream.once('error', () => finish(false));
475
+ });
476
+
477
+ // Клиент отключился во время ожидания первого токена — прерываем.
478
+ const onClientClose = () => {
479
+ try { result.stream.destroy(); } catch {}
480
+ };
481
+ req.once('close', onClientClose);
482
+
483
+ const gotFirst = await firstToken;
484
+ if (!gotFirst) {
485
+ const msg = key + ': no first token within 5s';
486
+ errors.push(msg);
487
+ recordFailure(key, 0);
488
+ recordRequest(key, false, msg);
489
+ recordRecent({ model: requestedModel, provider: key, status: 204, latency: result.latency, cached: false });
490
+ logger.warn('Streaming fallback: no first token', { key });
491
+ try { result.stream.destroy(); } catch {}
492
+ continue; // РАБОТАЕТ — мы внутри for-цикла провайдеров.
493
+ }
494
+
495
+ // Первый токен пришёл: пишем заголовки, промываем буфер, дальше стримим.
496
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
497
+ recordSuccess(key);
498
+ recordRequest(key, true);
499
+ logger.request({ model: requestedModel, provider: key, status: 200, latency: result.latency, stream: isStreaming });
500
+ recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
501
+ recordSelection(key, provider.model, requestedModel);
502
+ res.on('error', (err) => {
503
+ logger.error('Client stream error', { key, error: err.message });
504
+ try { result.stream.destroy(); } catch {}
505
+ });
506
+ for (const b of rawBuf) res.write(cleanStr(b));
507
+ rawBuf.length = 0;
508
+
509
+ // Убираем наш 'data'-слушатель (он больше не нужен — данные уже
510
+ // буферизованы в rawBuf и промыты). Дальше обрабатываем вручную.
511
+ result.stream.removeAllListeners('data');
512
+ result.stream.on('data', (chunk) => {
513
+ const str = chunk.toString();
514
+ collect(str);
515
+ res.write(cleanStr(str));
389
516
  });
390
517
  result.stream.on('end', () => {
391
- // Cache the assembled answer for repeat prompts (only if complete
392
- // AND non-empty empty answers must not be cached).
393
- if (chunks.length > 0) {
394
- const full = chunks.join('');
395
- if (full.trim().length > 0) {
518
+ const full = chunks.join('');
519
+ if (full.trim().length >= MIN_ANSWER_LEN) {
396
520
  cache.set(effectiveModel, body.messages, body.temperature, {
397
521
  id: 'chatcmpl-cached',
398
522
  object: 'chat.completion',
@@ -401,24 +525,24 @@ async function handleChatCompletion(req, res, body) {
401
525
  choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
402
526
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
403
527
  });
404
- }
405
528
  }
529
+ res.end();
406
530
  });
407
531
  result.stream.on('error', (err) => {
408
532
  logger.error('Stream error', { key, error: err.message });
409
533
  res.end();
410
534
  });
411
- result.stream.pipe(cleaner).pipe(res);
412
535
  return;
413
536
  }
414
537
 
415
538
  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);
539
+ // content already verified non-empty above
540
+ recordSuccess(key);
541
+ recordRequest(key, true);
542
+ logger.request({ model: requestedModel, provider: key, status: 200, latency: result.latency, stream: isStreaming });
543
+ recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
544
+ recordSelection(key, provider.model, requestedModel);
545
+ cache.set(effectiveModel, body.messages, body.temperature, result.data);
422
546
  recordTokens(key, result.usage);
423
547
  res.writeHead(200, { 'Content-Type': 'application/json' });
424
548
  res.end(JSON.stringify(result.data));
@@ -470,24 +594,82 @@ async function handleChatCompletion(req, res, body) {
470
594
  } finally {
471
595
  release();
472
596
  }
473
- recordSuccess(key);
474
- recordRequest(key, true);
475
- recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
476
597
  if (!body.stream && result.data) {
477
598
  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);
599
+ if (result.data.choices?.[0]) {
600
+ fixReasoningMessage(result.data.choices[0].message);
601
+ cleanMessage(result.data.choices[0].message);
602
+ }
603
+ if (isTooShort(result.data)) {
604
+ recordFailure(key, 0);
605
+ recordRequest(key, false, key + ': empty or too short response (retry)');
606
+ logger.warn('Empty or too-short response in retry, trying next', { key });
607
+ continue;
608
+ }
609
+ recordSuccess(key);
610
+ recordRequest(key, true);
611
+ recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
612
+ recordSelection(key, provider.model, requestedModel);
613
+ cache.set(effectiveModel, body.messages, body.temperature, result.data);
483
614
  recordTokens(key, result.usage);
484
615
  res.writeHead(200, { 'Content-Type': 'application/json' });
485
616
  res.end(JSON.stringify(result.data));
486
617
  return;
487
618
  }
488
619
  if (body.stream && result.stream) {
620
+ recordSuccess(key);
621
+ recordRequest(key, true);
622
+ recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
623
+ recordSelection(key, provider.model, requestedModel);
489
624
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
490
- result.stream.pipe(res);
625
+ const chunks = [];
626
+ const collectRetry = (str) => {
627
+ const lines = str.split('\n');
628
+ for (const line of lines) {
629
+ const m = line.match(/^data: (.+)$/);
630
+ if (!m || m[1].trim() === '[DONE]') continue;
631
+ try {
632
+ const obj = JSON.parse(m[1]);
633
+ const delta = obj.choices?.[0]?.delta?.content;
634
+ if (typeof delta === 'string') chunks.push(stripThink(delta, false));
635
+ } catch {}
636
+ }
637
+ };
638
+ const cleanRetry = (str) => str.replace(/^data: (.+)$/gm, (match, jsonStr) => {
639
+ if (jsonStr.trim() === '[DONE]') return match;
640
+ try {
641
+ const obj = JSON.parse(jsonStr);
642
+ delete obj.nvext;
643
+ if (obj.choices?.[0]) {
644
+ delete obj.choices[0].logprobs;
645
+ cleanDelta(obj.choices[0].delta);
646
+ }
647
+ return 'data: ' + JSON.stringify(obj);
648
+ } catch { return match; }
649
+ });
650
+ result.stream.on('data', (chunk) => {
651
+ const str = chunk.toString();
652
+ collectRetry(str);
653
+ res.write(cleanRetry(str));
654
+ });
655
+ result.stream.on('end', () => {
656
+ const full = chunks.join('');
657
+ if (full.trim().length >= MIN_ANSWER_LEN) {
658
+ cache.set(effectiveModel, body.messages, body.temperature, {
659
+ id: 'chatcmpl-cached',
660
+ object: 'chat.completion',
661
+ created: Math.floor(Date.now() / 1000),
662
+ model: provider.model,
663
+ choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
664
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
665
+ });
666
+ }
667
+ res.end();
668
+ });
669
+ result.stream.on('error', (err) => {
670
+ logger.error('Stream error (retry)', { key, error: err.message });
671
+ res.end();
672
+ });
491
673
  return;
492
674
  }
493
675
  } catch (err2) {