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 +11 -1
- package/lib/routing.js +21 -1
- package/package.json +1 -1
- package/providers.json +20 -0
- package/server.js +247 -65
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
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
|
-
|
|
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) {
|