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/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)); });