freegate 0.6.0
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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/README.ru.md +191 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +229 -0
- package/config.example.json +17 -0
- package/lib/cache.js +139 -0
- package/lib/clean.js +56 -0
- package/lib/dashboard.js +178 -0
- package/lib/health.js +179 -0
- package/lib/logger.js +48 -0
- package/lib/pool.js +41 -0
- package/lib/providers.js +215 -0
- package/lib/rateLimit.js +16 -0
- package/package.json +43 -0
- package/providers.json +298 -0
- package/server.js +662 -0
package/server.js
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
// server.js
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { LRUCache } = require('./lib/cache');
|
|
6
|
+
const { PROVIDERS, MODEL_MAP, callProvider } = require('./lib/providers');
|
|
7
|
+
const { loadState, initHealth, isCircuitOpen, recordSuccess, recordFailure, recordRequest, recordTokens, getHealth, getStats, recordRecent, recordRpm, getRecent, getRpm } = require('./lib/health');
|
|
8
|
+
const { checkRateLimit } = require('./lib/rateLimit');
|
|
9
|
+
const { handleDashboard } = require('./lib/dashboard');
|
|
10
|
+
const { acquire, stats: poolStats } = require('./lib/pool');
|
|
11
|
+
const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage } = require('./lib/clean');
|
|
12
|
+
const logger = require('./lib/logger');
|
|
13
|
+
|
|
14
|
+
// Load persisted state
|
|
15
|
+
loadState();
|
|
16
|
+
|
|
17
|
+
// Drop stale health entries for providers that no longer exist (e.g. auto-disabled)
|
|
18
|
+
const activeKeys = new Set(Object.keys(PROVIDERS));
|
|
19
|
+
const stale = Object.keys(getHealth()).filter(k => !activeKeys.has(k));
|
|
20
|
+
if (stale.length > 0) {
|
|
21
|
+
for (const k of stale) {
|
|
22
|
+
delete getHealth()[k];
|
|
23
|
+
}
|
|
24
|
+
logger.info('Cleaned stale health entries', { removed: stale });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cache = new LRUCache(500, 3600000);
|
|
28
|
+
require('./lib/cache')._activeCache = cache;
|
|
29
|
+
|
|
30
|
+
// Load config (with fallback so a corrupt config never crashes the server)
|
|
31
|
+
// Prefer cwd config.json (user's project) over the package dir.
|
|
32
|
+
const CONFIG_CANDIDATES = [path.join(process.cwd(), 'config.json'), path.join(__dirname, 'config.json')];
|
|
33
|
+
const CONFIG_PATH = CONFIG_CANDIDATES.find(p => fs.existsSync(p)) || CONFIG_CANDIDATES[1];
|
|
34
|
+
let config = { port: 4000, auth: '', rateLimit: { maxRequests: 100, windowMs: 60000 } };
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
37
|
+
if (parsed && typeof parsed === 'object') config = { ...config, ...parsed };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`Config corrupt, using defaults: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// CLI args (override config)
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
function getArg(name, defaultVal) {
|
|
45
|
+
const idx = args.indexOf('--' + name);
|
|
46
|
+
return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultVal;
|
|
47
|
+
}
|
|
48
|
+
const PORT = parseInt(process.env.PORT || getArg('port', config.port || '4000'));
|
|
49
|
+
const AUTH_KEY = process.env.AUTH || getArg('auth', config.auth || '');
|
|
50
|
+
const RATE_LIMIT = config.rateLimit || { maxRequests: 100, windowMs: 60000 };
|
|
51
|
+
|
|
52
|
+
// Health check
|
|
53
|
+
const healthIntervals = {}; // key -> { nextCheck, backoff }
|
|
54
|
+
|
|
55
|
+
async function checkProvider(key, provider) {
|
|
56
|
+
initHealth(key);
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
// CRITICAL: always use cheap /models GET for health checks. Sending real LLM
|
|
59
|
+
// requests just to "check health" burns provider daily request limits (Groq
|
|
60
|
+
// limits by requests/day, not tokens). 18 providers × every 5 min = 288
|
|
61
|
+
// wasted requests/day. Real latency comes from actual user requests instead.
|
|
62
|
+
try {
|
|
63
|
+
const url = provider.endpoint.replace('/chat/completions', '/models');
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
method: 'GET',
|
|
66
|
+
headers: {
|
|
67
|
+
...(provider.apiKey ? { 'Authorization': `Bearer ${provider.apiKey}` } : {}),
|
|
68
|
+
},
|
|
69
|
+
signal: AbortSignal.timeout(10000),
|
|
70
|
+
});
|
|
71
|
+
const latency = Date.now() - start;
|
|
72
|
+
if (res.ok) {
|
|
73
|
+
recordSuccess(key);
|
|
74
|
+
getHealth()[key].status = 'up';
|
|
75
|
+
// Do NOT record /models latency as generation speed (it's 30-100ms, not
|
|
76
|
+
// representative). Keep existing real latency from actual requests.
|
|
77
|
+
getHealth()[key].lastCheck = Date.now();
|
|
78
|
+
healthIntervals[key] = { nextCheck: Date.now() + 300000, backoff: 60000 };
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`HTTP ${res.status}`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const latency = Date.now() - start;
|
|
84
|
+
const is429 = String(err.message).includes('429');
|
|
85
|
+
// 429 = temporary rate limit (provider is alive, just limited). Don't mark it
|
|
86
|
+
// as dead — it will recover when the limit resets. Keep it visible to clients.
|
|
87
|
+
getHealth()[key].status = is429 ? 'ratelimited' : 'error';
|
|
88
|
+
getHealth()[key].latency = latency;
|
|
89
|
+
getHealth()[key].reason = is429 ? 'лимит провайдера (429)' : 'не отвечает';
|
|
90
|
+
getHealth()[key].score = Math.max(0, (getHealth()[key].score ?? 50) - (is429 ? 2 : 5));
|
|
91
|
+
recordFailure(key);
|
|
92
|
+
// 404 means the model/function isn't available for this account — auto-disable
|
|
93
|
+
// so we stop probing it forever. Re-enable by setting enabled:true in config.
|
|
94
|
+
if (String(err.message).includes('404')) {
|
|
95
|
+
provider.enabled = false;
|
|
96
|
+
getHealth()[key].status = 'disabled';
|
|
97
|
+
getHealth()[key].reason = 'отключён автоматически (404)';
|
|
98
|
+
logger.warn('Provider auto-disabled (404)', { key, model: provider.model });
|
|
99
|
+
} else {
|
|
100
|
+
// Re-check soon after a transient failure so the provider recovers quickly.
|
|
101
|
+
// Growing backoff (up to 10 min) would leave it 'dead' too long for users.
|
|
102
|
+
healthIntervals[key] = { nextCheck: Date.now() + 60000, backoff: 60000 };
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function healthCheck() {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
for (const [key, provider] of Object.entries(PROVIDERS)) {
|
|
111
|
+
if (!provider.enabled) continue;
|
|
112
|
+
const interval = healthIntervals[key];
|
|
113
|
+
if (interval && interval.nextCheck > now) continue;
|
|
114
|
+
await checkProvider(key, provider);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
setInterval(healthCheck, 30000);
|
|
118
|
+
setTimeout(healthCheck, 1000);
|
|
119
|
+
|
|
120
|
+
// Chat completion handler
|
|
121
|
+
async function handleChatCompletion(req, res, body) {
|
|
122
|
+
const requestedModel = body.model || 'tier-splus';
|
|
123
|
+
let targetProviderKey = MODEL_MAP[requestedModel] || 'zai';
|
|
124
|
+
const isStreaming = body.stream === true;
|
|
125
|
+
|
|
126
|
+
// Vision detection: if the request contains images, route to a vision provider.
|
|
127
|
+
// TWO-STAGE pipeline:
|
|
128
|
+
// Stage 1: vision model reads the screenshot, extracts text/description.
|
|
129
|
+
// Stage 2: the requested (coding/general) model answers using the extracted
|
|
130
|
+
// text as context — so a coding model handles the fix, not vision.
|
|
131
|
+
const hasImage = Array.isArray(body.messages) && body.messages.some((m) => {
|
|
132
|
+
if (Array.isArray(m.content)) {
|
|
133
|
+
return m.content.some((c) => c && (c.type === 'image_url' || c.type === 'image'));
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
});
|
|
137
|
+
if (hasImage) {
|
|
138
|
+
// Two-stage pipeline: try vision providers in order until one extracts text.
|
|
139
|
+
const visionChain = ['gemini-vision', 'nim-vision', 'deepseek-vision']
|
|
140
|
+
.map((k) => PROVIDERS[k])
|
|
141
|
+
.filter((p) => p && p.enabled);
|
|
142
|
+
if (visionChain.length > 0) {
|
|
143
|
+
logger.info('Vision pipeline: распознаю скриншот', { chain: visionChain.map(p => p.key).join(',') });
|
|
144
|
+
let extracted = '';
|
|
145
|
+
for (const visionProvider of visionChain) {
|
|
146
|
+
try {
|
|
147
|
+
const visionBody = {
|
|
148
|
+
model: visionProvider.model,
|
|
149
|
+
messages: [{
|
|
150
|
+
role: 'user',
|
|
151
|
+
content: [
|
|
152
|
+
{ type: 'text', text: 'Распознай и извлеки ВЕСЬ текст с изображения (ошибка, код, сообщение). Верни только содержимое, без комментариев. Если это код — верни код как есть.' },
|
|
153
|
+
...(Array.isArray(body.messages) ? body.messages.flatMap((m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url' || c.type === 'image') : [])) : []),
|
|
154
|
+
],
|
|
155
|
+
}],
|
|
156
|
+
max_tokens: 2000,
|
|
157
|
+
};
|
|
158
|
+
const visionRes = await callProvider(visionProvider, visionBody);
|
|
159
|
+
extracted = visionRes.data?.choices?.[0]?.message?.content || visionRes.data?.choices?.[0]?.message?.reasoning || '';
|
|
160
|
+
if (extracted) { logger.info('Vision pipeline: распознал ' + visionProvider.key); break; }
|
|
161
|
+
} catch (err) {
|
|
162
|
+
logger.warn('Vision pipeline: ' + visionProvider.key + ' не сработал', { error: err.message.slice(0, 80) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const cleaned = stripThink(extracted, true);
|
|
166
|
+
logger.info('Vision pipeline: скриншот распознан', { chars: cleaned.length });
|
|
167
|
+
if (cleaned) {
|
|
168
|
+
// Replace image content with the extracted text as context,
|
|
169
|
+
// so the coding/general model (not vision) answers the question.
|
|
170
|
+
const userMsgs = Array.isArray(body.messages) ? body.messages : [];
|
|
171
|
+
body = {
|
|
172
|
+
...body,
|
|
173
|
+
messages: userMsgs.map((m) => {
|
|
174
|
+
if (Array.isArray(m.content) && m.content.some((c) => c.type === 'image_url' || c.type === 'image')) {
|
|
175
|
+
return { role: 'user', content: `${m.content.find((c) => c.type === 'text')?.text || ''}\n\n[Содержимое скриншота]\n${cleaned}` };
|
|
176
|
+
}
|
|
177
|
+
return m;
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check cache (works for both streaming and non-streaming)
|
|
185
|
+
const cached = cache.get(requestedModel, body.messages, body.temperature);
|
|
186
|
+
if (cached) {
|
|
187
|
+
logger.request({ model: requestedModel, provider: 'cache', status: 200, cached: true });
|
|
188
|
+
recordRecent({ model: requestedModel, provider: 'cache', status: 200, latency: 0, cached: true });
|
|
189
|
+
if (isStreaming) {
|
|
190
|
+
// Replay cached answer as an SSE stream
|
|
191
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
192
|
+
const content = cached.choices?.[0]?.message?.content || '';
|
|
193
|
+
res.write(`data: ${JSON.stringify({ id: 'chatcmpl-cached', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: cached.model, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] })}\n\n`);
|
|
194
|
+
res.write(`data: ${JSON.stringify({ id: 'chatcmpl-cached', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: cached.model, choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\n`);
|
|
195
|
+
res.write(`data: ${JSON.stringify({ id: 'chatcmpl-cached', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: cached.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`);
|
|
196
|
+
res.write('data: [DONE]\n\n');
|
|
197
|
+
res.end();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
201
|
+
res.end(JSON.stringify(cached));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Weighted selection among healthy providers
|
|
206
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
207
|
+
// 'ratelimited' providers are alive but temporarily limited — include them
|
|
208
|
+
// (weighted down) so the pool never looks empty when many limits are hot.
|
|
209
|
+
const healthyProviders = Object.entries(PROVIDERS)
|
|
210
|
+
.filter(([_, p]) => p.enabled && !isCircuitOpen(p.key) &&
|
|
211
|
+
(getHealth()[p.key]?.status === 'up' || getHealth()[p.key]?.status === 'ratelimited'));
|
|
212
|
+
|
|
213
|
+
// Prefer providers below 90% of their daily limit; only fall back to
|
|
214
|
+
// near-exhausted ones if that leaves nothing (avoids avoidable 429s).
|
|
215
|
+
let pool = healthyProviders;
|
|
216
|
+
const underLimit = healthyProviders.filter(([_, p]) => {
|
|
217
|
+
const limit = p.dailyLimit || 1000;
|
|
218
|
+
const used = (getStats().dailyUsage?.[p.key]?.[today]) || 0;
|
|
219
|
+
return used < limit * 0.9;
|
|
220
|
+
});
|
|
221
|
+
if (underLimit.length > 0) pool = underLimit;
|
|
222
|
+
|
|
223
|
+
let selected = [];
|
|
224
|
+
if (pool.length > 0) {
|
|
225
|
+
const scored = pool.map(([key, provider]) => {
|
|
226
|
+
const h = getHealth()[key];
|
|
227
|
+
let score = h.score || 50;
|
|
228
|
+
// Latency is only reliable after real requests; unmeasured/zero latency
|
|
229
|
+
// must NOT balloon a provider's weight. Treat <100ms as neutral.
|
|
230
|
+
const rawLat = h.latency || 0;
|
|
231
|
+
const lat = rawLat > 0 ? Math.max(rawLat, 100) : 500;
|
|
232
|
+
let weight = score / lat;
|
|
233
|
+
// Rate-limited providers are last resort — heavy penalty
|
|
234
|
+
if (h.status === 'ratelimited') weight *= 0.05;
|
|
235
|
+
if (key === targetProviderKey) weight *= 2;
|
|
236
|
+
const dailyLimit = provider.dailyLimit || 1000;
|
|
237
|
+
const usedToday = getStats().providerUsage[key] || 0;
|
|
238
|
+
if (usedToday >= dailyLimit * 0.9) weight *= 0.5;
|
|
239
|
+
// Providers with a history of failures lose weight (stability first).
|
|
240
|
+
// Use TODAY's failure count (reliability.fail) so overloaded providers
|
|
241
|
+
// are excluded now but recover next day. Heavy count → near-exclusion.
|
|
242
|
+
const relToday = getStats().reliability?.[key];
|
|
243
|
+
const todayFails = relToday?.day === today ? (relToday.fail || 0) : 0;
|
|
244
|
+
if (todayFails > 5) weight *= 0.4;
|
|
245
|
+
if (todayFails > 20) weight *= 0.05;
|
|
246
|
+
// Today's reliability: providers that have been 100% successful get a boost
|
|
247
|
+
const rel = getStats().reliability?.[key];
|
|
248
|
+
if (rel && rel.success + rel.fail >= 3) {
|
|
249
|
+
const ratio = rel.success / (rel.success + rel.fail);
|
|
250
|
+
if (ratio === 1) weight *= 1.3;
|
|
251
|
+
else if (ratio < 0.5) weight *= 0.5;
|
|
252
|
+
}
|
|
253
|
+
return { key, provider, weight };
|
|
254
|
+
}).sort((a, b) => b.weight - a.weight);
|
|
255
|
+
|
|
256
|
+
const totalWeight = scored.reduce((s, p) => s + p.weight, 0);
|
|
257
|
+
let r = Math.random() * totalWeight;
|
|
258
|
+
for (const p of scored) {
|
|
259
|
+
r -= p.weight;
|
|
260
|
+
if (r <= 0) { selected = scored; break; }
|
|
261
|
+
}
|
|
262
|
+
if (selected.length === 0) selected = scored;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const enabledProviders = selected.length > 0 ? selected.map(s => [s.key, s.provider]) :
|
|
266
|
+
Object.entries(PROVIDERS).filter(([_, p]) => p.enabled)
|
|
267
|
+
.sort((a, b) => (getHealth()[b[0]]?.score || 50) - (getHealth()[a[0]]?.score || 50));
|
|
268
|
+
|
|
269
|
+
// The requested model's mapped provider MUST be tried first — even for
|
|
270
|
+
// tier aliases. Without this, weighted selection may route the agent to a
|
|
271
|
+
// slow/empty-streaming provider (e.g. Nemotron-120b) instead of the fast one.
|
|
272
|
+
if (MODEL_MAP[requestedModel] && PROVIDERS[targetProviderKey]) {
|
|
273
|
+
// Ensure the target provider is in the candidate list at all
|
|
274
|
+
if (!enabledProviders.some(([k]) => k === targetProviderKey)) {
|
|
275
|
+
enabledProviders.unshift([targetProviderKey, PROVIDERS[targetProviderKey]]);
|
|
276
|
+
}
|
|
277
|
+
const targetIdx = enabledProviders.findIndex(([k]) => k === targetProviderKey);
|
|
278
|
+
if (targetIdx > 0) {
|
|
279
|
+
const [t] = enabledProviders.splice(targetIdx, 1);
|
|
280
|
+
enabledProviders.unshift(t);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (enabledProviders.length === 0) {
|
|
285
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
286
|
+
res.end(JSON.stringify({ error: 'No providers available' }));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const errors = [];
|
|
291
|
+
|
|
292
|
+
for (const [key, provider] of enabledProviders) {
|
|
293
|
+
if (isCircuitOpen(key)) {
|
|
294
|
+
errors.push(key + ': circuit breaker open');
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const providerBody = { ...body, model: provider.model };
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const release = await acquire(key);
|
|
302
|
+
let result;
|
|
303
|
+
try {
|
|
304
|
+
result = await callProvider(provider, providerBody);
|
|
305
|
+
} finally {
|
|
306
|
+
release();
|
|
307
|
+
}
|
|
308
|
+
initHealth(key);
|
|
309
|
+
// Cap recorded latency — values >60s mean the request hung, not real
|
|
310
|
+
// provider speed. Huge latency would poison the weighted selection.
|
|
311
|
+
getHealth()[key].latency = Math.min(result.latency || 0, 60000);
|
|
312
|
+
getHealth()[key].lastCheck = Date.now();
|
|
313
|
+
recordSuccess(key);
|
|
314
|
+
recordRequest(key, true);
|
|
315
|
+
logger.request({ model: requestedModel, provider: key, status: 200, latency: result.latency, stream: isStreaming });
|
|
316
|
+
recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
|
|
317
|
+
|
|
318
|
+
if (isStreaming && result.stream) {
|
|
319
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
320
|
+
const { Transform } = require('stream');
|
|
321
|
+
// Accumulate content deltas so we can cache the final answer for
|
|
322
|
+
// identical repeat prompts (opencode always streams).
|
|
323
|
+
const chunks = [];
|
|
324
|
+
const cleaner = new Transform({
|
|
325
|
+
transform(chunk, encoding, callback) {
|
|
326
|
+
const str = chunk.toString();
|
|
327
|
+
// Collect SSE JSON payloads for caching (strip think blocks)
|
|
328
|
+
const lines = str.split('\n');
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
const m = line.match(/^data: (.+)$/);
|
|
331
|
+
if (!m || m[1].trim() === '[DONE]') continue;
|
|
332
|
+
try {
|
|
333
|
+
const obj = JSON.parse(m[1]);
|
|
334
|
+
const delta = obj.choices?.[0]?.delta?.content;
|
|
335
|
+
if (typeof delta === 'string') chunks.push(stripThink(delta, false));
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
const cleaned = str.replace(/^data: (.+)$/gm, (match, jsonStr) => {
|
|
339
|
+
if (jsonStr.trim() === '[DONE]') return match;
|
|
340
|
+
try {
|
|
341
|
+
const obj = JSON.parse(jsonStr);
|
|
342
|
+
delete obj.nvext;
|
|
343
|
+
if (obj.choices?.[0]) {
|
|
344
|
+
delete obj.choices[0].logprobs;
|
|
345
|
+
cleanDelta(obj.choices[0].delta);
|
|
346
|
+
}
|
|
347
|
+
return 'data: ' + JSON.stringify(obj);
|
|
348
|
+
} catch { return match; }
|
|
349
|
+
});
|
|
350
|
+
callback(null, cleaned);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
result.stream.on('end', () => {
|
|
354
|
+
// Cache the assembled answer for repeat prompts (only if complete)
|
|
355
|
+
if (chunks.length > 0) {
|
|
356
|
+
const full = chunks.join('');
|
|
357
|
+
cache.set(requestedModel, body.messages, body.temperature, {
|
|
358
|
+
id: 'chatcmpl-cached',
|
|
359
|
+
object: 'chat.completion',
|
|
360
|
+
created: Math.floor(Date.now() / 1000),
|
|
361
|
+
model: provider.model,
|
|
362
|
+
choices: [{ index: 0, message: { role: 'assistant', content: full }, finish_reason: 'stop' }],
|
|
363
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
result.stream.on('error', (err) => {
|
|
368
|
+
logger.error('Stream error', { key, error: err.message });
|
|
369
|
+
res.end();
|
|
370
|
+
});
|
|
371
|
+
result.stream.pipe(cleaner).pipe(res);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!isStreaming && result.data) {
|
|
376
|
+
delete result.data.nvext;
|
|
377
|
+
if (result.data.choices?.[0]) {
|
|
378
|
+
fixReasoningMessage(result.data.choices[0].message);
|
|
379
|
+
cleanMessage(result.data.choices[0].message);
|
|
380
|
+
}
|
|
381
|
+
cache.set(requestedModel, body.messages, body.temperature, result.data);
|
|
382
|
+
recordTokens(key, result.usage);
|
|
383
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
384
|
+
res.end(JSON.stringify(result.data));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
const statusCode = err.statusCode || 502;
|
|
389
|
+
errors.push(err.message);
|
|
390
|
+
recordRequest(key, false, err.message);
|
|
391
|
+
recordRecent({ model: requestedModel, provider: key, status: statusCode, latency: 0, cached: false });
|
|
392
|
+
initHealth(key);
|
|
393
|
+
// Do NOT flip provider to 'error' on a single failed request — transient
|
|
394
|
+
// failures (timeout, 5xx, one-off 429) shouldn't kill a healthy provider.
|
|
395
|
+
// The circuit breaker (3 failures) and periodic health-check handle that.
|
|
396
|
+
// Only a 429 marks it ratelimited (informational); 404 disables entirely.
|
|
397
|
+
if (statusCode === 429) {
|
|
398
|
+
getHealth()[key].status = 'ratelimited';
|
|
399
|
+
getHealth()[key].reason = 'лимит провайдера (429)';
|
|
400
|
+
} else if (statusCode !== 404 && getHealth()[key].status === 'up') {
|
|
401
|
+
// keep 'up' — it may just be a transient blip; health-check re-verifies
|
|
402
|
+
} else {
|
|
403
|
+
getHealth()[key].status = 'error';
|
|
404
|
+
getHealth()[key].reason = 'не отвечает';
|
|
405
|
+
}
|
|
406
|
+
getHealth()[key].score = Math.max(0, (getHealth()[key].score || 50) - (statusCode === 429 ? 5 : 10));
|
|
407
|
+
recordFailure(key, statusCode);
|
|
408
|
+
// 404 = model not available for this account — disable permanently
|
|
409
|
+
if (statusCode === 404) {
|
|
410
|
+
provider.enabled = false;
|
|
411
|
+
getHealth()[key].status = 'disabled';
|
|
412
|
+
getHealth()[key].reason = 'отключён автоматически (404)';
|
|
413
|
+
logger.warn('Provider auto-disabled (404)', { key, model: provider.model });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// If every provider failed with transient errors (5xx/timeout), give them one
|
|
419
|
+
// more pass after a short pause — many overloads clear in 1-2 seconds.
|
|
420
|
+
const allSoft = errors.length > 0 && errors.every(e => !/429|401|403|404/.test(e));
|
|
421
|
+
if (allSoft && enabledProviders.length > 1) {
|
|
422
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
423
|
+
for (const [key, provider] of enabledProviders) {
|
|
424
|
+
if (isCircuitOpen(key)) continue;
|
|
425
|
+
try {
|
|
426
|
+
const release = await acquire(key);
|
|
427
|
+
let result;
|
|
428
|
+
try {
|
|
429
|
+
result = await callProvider(provider, { ...body, model: provider.model });
|
|
430
|
+
} finally {
|
|
431
|
+
release();
|
|
432
|
+
}
|
|
433
|
+
recordSuccess(key);
|
|
434
|
+
recordRequest(key, true);
|
|
435
|
+
recordRecent({ model: requestedModel, provider: key, status: 200, latency: result.latency, cached: false });
|
|
436
|
+
if (!body.stream && result.data) {
|
|
437
|
+
delete result.data.nvext;
|
|
438
|
+
if (result.data.choices?.[0]) {
|
|
439
|
+
fixReasoningMessage(result.data.choices[0].message);
|
|
440
|
+
cleanMessage(result.data.choices[0].message);
|
|
441
|
+
}
|
|
442
|
+
cache.set(requestedModel, body.messages, body.temperature, result.data);
|
|
443
|
+
recordTokens(key, result.usage);
|
|
444
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
445
|
+
res.end(JSON.stringify(result.data));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (body.stream && result.stream) {
|
|
449
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
450
|
+
result.stream.pipe(res);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
} catch (err2) {
|
|
454
|
+
recordRequest(key, false, err2.message);
|
|
455
|
+
recordFailure(key, err2.statusCode);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
461
|
+
res.end(JSON.stringify({ error: { message: 'All providers failed', type: 'api_error', code: 'all_providers_failed', details: errors } }));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Server
|
|
465
|
+
const server = http.createServer(async (req, res) => {
|
|
466
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
467
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
468
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
469
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
470
|
+
|
|
471
|
+
const parsedUrl = new URL(req.url, 'http://localhost:' + PORT);
|
|
472
|
+
|
|
473
|
+
if (parsedUrl.pathname === '/') {
|
|
474
|
+
// Protect the dashboard with auth if one is configured.
|
|
475
|
+
// Browser-friendly: accept ?key= or Authorization header.
|
|
476
|
+
if (AUTH_KEY) {
|
|
477
|
+
const keyFromQuery = parsedUrl.searchParams.get('key');
|
|
478
|
+
const keyFromHeader = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
|
479
|
+
if (keyFromQuery !== AUTH_KEY && keyFromHeader !== AUTH_KEY) {
|
|
480
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
481
|
+
res.end(JSON.stringify({ error: { message: 'Invalid API key', code: 'invalid_api_key' } }));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
handleDashboard(req, res);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (parsedUrl.pathname === '/health') {
|
|
490
|
+
const h = getHealth();
|
|
491
|
+
const upCount = Object.values(h).filter(v => v.status === 'up').length;
|
|
492
|
+
const totalCount = Object.entries(PROVIDERS).filter(([_, p]) => p.enabled).length;
|
|
493
|
+
res.writeHead(upCount > 0 ? 200 : 503, { 'Content-Type': 'application/json' });
|
|
494
|
+
res.end(JSON.stringify({ status: upCount > 0 ? 'ok' : 'degraded', providers: { up: upCount, total: totalCount } }));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (parsedUrl.pathname === '/v1/stats') {
|
|
499
|
+
const s = getStats();
|
|
500
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
501
|
+
const limits = {};
|
|
502
|
+
for (const [key, p] of Object.entries(PROVIDERS)) {
|
|
503
|
+
const limit = p.dailyLimit;
|
|
504
|
+
if (!limit) continue;
|
|
505
|
+
const used = (s.dailyUsage?.[key]?.[today]) || 0;
|
|
506
|
+
limits[key] = { limit, used, remaining: Math.max(0, limit - used), percent: Math.min(100, Math.round((used / limit) * 100)) };
|
|
507
|
+
}
|
|
508
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
509
|
+
res.end(JSON.stringify({
|
|
510
|
+
total_requests: s.totalRequests, successful_requests: s.successfulRequests, failed_requests: s.failedRequests,
|
|
511
|
+
provider_usage: s.providerUsage, token_usage: s.tokenUsage, errors: s.errors, uptime_seconds: Math.floor((Date.now() - s.startTime) / 1000),
|
|
512
|
+
health: Object.fromEntries(Object.entries(getHealth()).map(([k, v]) => {
|
|
513
|
+
const limit = limits[k];
|
|
514
|
+
const err = s.errors[k] || 0;
|
|
515
|
+
let reason = '';
|
|
516
|
+
if (v.status !== 'up') reason = v.status === 'ratelimited' ? 'лимит провайдера (429)' : 'не отвечает';
|
|
517
|
+
else if (limit && limit.percent >= 100) reason = 'дневной лимит исчерпан';
|
|
518
|
+
else if (err > 10) reason = 'много ошибок (' + err + ')';
|
|
519
|
+
else reason = 'работает';
|
|
520
|
+
const rel = s.reliability?.[k];
|
|
521
|
+
let reliability = null;
|
|
522
|
+
if (rel && rel.success + rel.fail >= 3) reliability = Math.round((rel.success / (rel.success + rel.fail)) * 100);
|
|
523
|
+
return [k, { status: v.status, score: v.score, latency_ms: v.latency, reason, reliability }];
|
|
524
|
+
})),
|
|
525
|
+
cache: cache.stats(),
|
|
526
|
+
limits,
|
|
527
|
+
pool: poolStats(),
|
|
528
|
+
}));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (parsedUrl.pathname === '/v1/models') {
|
|
533
|
+
const models = Object.entries(PROVIDERS).filter(([_, p]) => p.enabled).map(([key, p]) => ({ id: p.model, object: 'model', owned_by: key }));
|
|
534
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
535
|
+
res.end(JSON.stringify({ object: 'list', data: models }));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (parsedUrl.pathname === '/v1/chat/completions' && req.method === 'POST') {
|
|
540
|
+
if (AUTH_KEY) {
|
|
541
|
+
const apiKey = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
|
542
|
+
if (apiKey !== AUTH_KEY) {
|
|
543
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
544
|
+
res.end(JSON.stringify({ error: { message: 'Invalid API key' } }));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const serviceKey = req.socket.remoteAddress;
|
|
550
|
+
if (!checkRateLimit(serviceKey, RATE_LIMIT.maxRequests, RATE_LIMIT.windowMs)) {
|
|
551
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
552
|
+
res.end(JSON.stringify({ error: { message: 'Rate limit exceeded' } }));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let body = '';
|
|
557
|
+
const MAX_BODY = 2 * 1024 * 1024; // 2MB cap
|
|
558
|
+
req.on('data', chunk => {
|
|
559
|
+
body += chunk;
|
|
560
|
+
if (body.length > MAX_BODY) {
|
|
561
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
562
|
+
res.end(JSON.stringify({ error: { message: 'Request body too large' } }));
|
|
563
|
+
req.destroy();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
req.on('error', () => {});
|
|
567
|
+
req.on('end', async () => {
|
|
568
|
+
try {
|
|
569
|
+
const requestBody = JSON.parse(body);
|
|
570
|
+
// Validate minimal structure — reject junk before it burns provider limits
|
|
571
|
+
if (!requestBody || typeof requestBody !== 'object' ||
|
|
572
|
+
!Array.isArray(requestBody.messages) || requestBody.messages.length === 0) {
|
|
573
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
574
|
+
res.end(JSON.stringify({ error: { message: 'messages is required and must be a non-empty array', type: 'invalid_request_error', code: 'invalid_messages' } }));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
await handleChatCompletion(req, res, requestBody);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
logger.error('Chat handler error', { message: err.message, stack: err.stack });
|
|
580
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
581
|
+
res.end(JSON.stringify({ error: { message: 'Invalid request body' } }));
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (parsedUrl.pathname === '/v1/recent') {
|
|
588
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
589
|
+
res.end(JSON.stringify({ data: getRecent() }));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (parsedUrl.pathname === '/v1/rpm') {
|
|
594
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
595
|
+
res.end(JSON.stringify({ data: getRpm() }));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// POST /v1/shorts — generate a vertical short video via the tools generator.
|
|
600
|
+
// Body: { prompt, duration?, format? ("9:16"/"16:9"/"1:1"), steps? }
|
|
601
|
+
if (parsedUrl.pathname === '/v1/shorts' && req.method === 'POST') {
|
|
602
|
+
if (AUTH_KEY) {
|
|
603
|
+
const apiKey = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
|
604
|
+
if (apiKey !== AUTH_KEY) {
|
|
605
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
606
|
+
res.end(JSON.stringify({ error: { message: 'Invalid API key' } }));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
let body = '';
|
|
611
|
+
req.on('data', (c) => body += c);
|
|
612
|
+
req.on('end', async () => {
|
|
613
|
+
try {
|
|
614
|
+
const params = JSON.parse(body);
|
|
615
|
+
if (!params.prompt) {
|
|
616
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
617
|
+
res.end(JSON.stringify({ error: { message: 'prompt is required' } }));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const { execFile } = require('child_process');
|
|
621
|
+
const toolsDir = path.join(__dirname, 'tools');
|
|
622
|
+
const py = path.join(toolsDir, '.venv', 'bin', 'python');
|
|
623
|
+
const script = path.join(toolsDir, 'generate_shorts.py');
|
|
624
|
+
const args = [script, params.prompt];
|
|
625
|
+
if (params.duration) args.push('--duration', String(params.duration));
|
|
626
|
+
if (params.format) args.push('--format', params.format);
|
|
627
|
+
if (params.steps) args.push('--steps', String(params.steps));
|
|
628
|
+
logger.info('Shorts generation requested', { prompt: params.prompt.slice(0, 60) });
|
|
629
|
+
execFile(py, args, { cwd: toolsDir, timeout: 600000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
630
|
+
if (err) {
|
|
631
|
+
logger.error('Shorts generation failed', { error: err.message, stderr: String(stderr).slice(0, 300) });
|
|
632
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
633
|
+
res.end(JSON.stringify({ error: { message: 'Generation failed: ' + err.message, detail: String(stderr).slice(0, 300) } }));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// Parse absolute .mp4 paths from stdout
|
|
637
|
+
const files = String(stdout).split('\n')
|
|
638
|
+
.map(l => l.trim())
|
|
639
|
+
.filter(l => l.includes('.mp4') && l.startsWith('/'))
|
|
640
|
+
.map(l => l.split(' ').pop().trim());
|
|
641
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
642
|
+
res.end(JSON.stringify({ ok: true, files, stdout: String(stdout).slice(0, 2000) }));
|
|
643
|
+
});
|
|
644
|
+
} catch (e) {
|
|
645
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
646
|
+
res.end(JSON.stringify({ error: { message: 'Invalid request: ' + e.message } }));
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
653
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
server.listen(PORT, process.env.HOST || '127.0.0.1', () => {
|
|
657
|
+
logger.info('Freegate started', { port: PORT });
|
|
658
|
+
console.log('Dashboard: http://localhost:' + PORT + '/');
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
process.on('SIGINT', () => { require('./lib/health').saveState(); cache.persist(); server.close(() => process.exit(0)); });
|
|
662
|
+
process.on('SIGTERM', () => { require('./lib/health').saveState(); cache.persist(); server.close(() => process.exit(0)); });
|