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/lib/cache.js ADDED
@@ -0,0 +1,139 @@
1
+ // lib/cache.js
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MAX_SIZE = 500;
7
+ const DEFAULT_TTL = 3600000; // 1 hour in ms
8
+ const MAX_ENTRY_BYTES = 256 * 1024; // don't persist responses larger than 256KB
9
+ const CACHE_PATH = path.join(__dirname, '..', 'cache.json');
10
+
11
+ class LRUCache {
12
+ constructor(maxSize = MAX_SIZE, ttl = DEFAULT_TTL, skipLoad = false) {
13
+ this.maxSize = maxSize;
14
+ this.ttl = ttl;
15
+ this.cache = new Map();
16
+ this.hits = 0;
17
+ this.misses = 0;
18
+ if (!skipLoad) this.load();
19
+ }
20
+
21
+ _key(model, messages, temperature) {
22
+ const raw = `${model}|${JSON.stringify(messages)}|${temperature || 0}`;
23
+ return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
24
+ }
25
+
26
+ load() {
27
+ // Restore persisted cache from disk (survives restarts)
28
+ try {
29
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
30
+ if (data && Array.isArray(data.entries)) {
31
+ const now = Date.now();
32
+ for (const e of data.entries) {
33
+ if (now - e.created > this.ttl) continue;
34
+ this.cache.set(e.key, { value: e.value, created: e.created, uses: e.uses || 0 });
35
+ }
36
+ // Restore hit/miss counters so hitRate survives restarts
37
+ if (typeof data.hits === 'number') this.hits = data.hits;
38
+ if (typeof data.misses === 'number') this.misses = data.misses;
39
+ }
40
+ } catch {}
41
+ }
42
+
43
+ persist() {
44
+ try {
45
+ const now = Date.now();
46
+ const entries = [];
47
+ for (const [key, entry] of this.cache) {
48
+ if (now - entry.created > this.ttl) continue;
49
+ try {
50
+ const size = Buffer.byteLength(JSON.stringify(entry.value));
51
+ if (size > MAX_ENTRY_BYTES) continue;
52
+ entries.push({ key, value: entry.value, created: entry.created, uses: entry.uses || 0 });
53
+ } catch {}
54
+ }
55
+ // Keep only the most recent 300 on disk to bound file size
56
+ const trimmed = entries.slice(-300);
57
+ fs.writeFileSync(CACHE_PATH, JSON.stringify({
58
+ saved: Date.now(),
59
+ hits: this.hits,
60
+ misses: this.misses,
61
+ entries: trimmed,
62
+ }));
63
+ } catch {}
64
+ }
65
+
66
+ get(model, messages, temperature) {
67
+ const key = this._key(model, messages, temperature);
68
+ const entry = this.cache.get(key);
69
+
70
+ if (!entry) {
71
+ this.misses++;
72
+ return null;
73
+ }
74
+
75
+ if (Date.now() - entry.created > this.ttl) {
76
+ this.cache.delete(key);
77
+ this.misses++;
78
+ return null;
79
+ }
80
+
81
+ // Hot entries: bump TTL + usage count so frequently-used responses
82
+ // stay cached longer (mirrors DeepSeek-style high cache-hit rates).
83
+ entry.created = Date.now();
84
+ entry.uses = (entry.uses || 0) + 1;
85
+
86
+ // Move to end (most recently used)
87
+ this.cache.delete(key);
88
+ this.cache.set(key, entry);
89
+ this.hits++;
90
+ return entry.value;
91
+ }
92
+
93
+ set(model, messages, temperature, value) {
94
+ const key = this._key(model, messages, temperature);
95
+
96
+ // Delete if exists (to update order)
97
+ if (this.cache.has(key)) this.cache.delete(key);
98
+
99
+ // Evict least-used entry if at capacity (not just oldest)
100
+ if (this.cache.size >= this.maxSize) {
101
+ let victim = null;
102
+ let minUses = Infinity;
103
+ for (const [k, e] of this.cache) {
104
+ const u = e.uses || 0;
105
+ if (u < minUses) { minUses = u; victim = k; }
106
+ }
107
+ if (victim) this.cache.delete(victim);
108
+ }
109
+
110
+ this.cache.set(key, { value, created: Date.now(), uses: 0 });
111
+ }
112
+
113
+ stats() {
114
+ return {
115
+ hits: this.hits,
116
+ misses: this.misses,
117
+ size: this.cache.size,
118
+ maxSize: this.maxSize,
119
+ hitRate: this.hits + this.misses > 0
120
+ ? Math.round((this.hits / (this.hits + this.misses)) * 100)
121
+ : 0,
122
+ };
123
+ }
124
+
125
+ clear() {
126
+ this.cache.clear();
127
+ this.hits = 0;
128
+ this.misses = 0;
129
+ try { fs.unlinkSync(CACHE_PATH); } catch {}
130
+ }
131
+ }
132
+
133
+ // Auto-persist every 60 seconds
134
+ const _persistTimer = setInterval(() => {
135
+ const c = module.exports._activeCache;
136
+ if (c) c.persist();
137
+ }, 60000);
138
+
139
+ module.exports = { LRUCache, _stopTimers: () => clearInterval(_persistTimer) };
package/lib/clean.js ADDED
@@ -0,0 +1,56 @@
1
+ // lib/clean.js — response cleaning helpers.
2
+ // Strips <think>...</think> reasoning blocks that some free models
3
+ // (qwen, nemotron) inject into answers — they pollute short content.
4
+
5
+ // Remove think blocks from a text string (case-insensitive, greedy-safe).
6
+ // trim=true also trims whitespace (safe for full messages, NOT streaming deltas
7
+ // — trimming each streamed chunk eats the spaces between words).
8
+ function stripThink(text, trim = true) {
9
+ if (!text) return text;
10
+ let out = text;
11
+ // Handle <think> and <thinking> tags in any case, spanning newlines
12
+ out = out.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '');
13
+ out = out.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '');
14
+ if (trim) {
15
+ out = out.replace(/\n{3,}/g, '\n\n').trim();
16
+ } else {
17
+ // Collapse 3+ newlines into 2, but keep leading/trailing spaces intact
18
+ out = out.replace(/\n{3,}/g, '\n\n');
19
+ }
20
+ return out;
21
+ }
22
+
23
+ // Strip think content from a non-streaming completion message (in place).
24
+ function cleanMessage(message) {
25
+ if (!message) return;
26
+ if (typeof message.content === 'string') {
27
+ message.content = stripThink(message.content, true);
28
+ }
29
+ return message;
30
+ }
31
+
32
+ // Fix reasoning-model responses: some free models (gpt-oss-120b, nemotron)
33
+ // put the answer in `reasoning` and leave `content` empty. If content is
34
+ // empty but reasoning has text, surface reasoning as the answer.
35
+ function fixReasoningMessage(message) {
36
+ if (!message) return;
37
+ const content = typeof message.content === 'string' ? message.content.trim() : '';
38
+ const reasoning = typeof message.reasoning === 'string' ? message.reasoning.trim() : '';
39
+ if (!content && reasoning) {
40
+ message.content = stripThink(reasoning, true);
41
+ }
42
+ return message;
43
+ }
44
+
45
+ // Strip think content from a streaming delta (in place).
46
+ // Do NOT trim — streaming chunks must keep their boundary spaces so words
47
+ // don't merge when the client reassembles the stream.
48
+ function cleanDelta(delta) {
49
+ if (!delta) return;
50
+ if (typeof delta.content === 'string') {
51
+ delta.content = stripThink(delta.content, false);
52
+ }
53
+ return delta;
54
+ }
55
+
56
+ module.exports = { stripThink, cleanMessage, cleanDelta, fixReasoningMessage };
@@ -0,0 +1,178 @@
1
+ // lib/dashboard.js
2
+ const HTML = `<!DOCTYPE html>
3
+ <html lang="ru">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Freegate — Панель управления</title>
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
11
+ h1 { color: #00d4ff; margin-bottom: 20px; }
12
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
13
+ .card { background: #16213e; border-radius: 8px; padding: 20px; border: 1px solid #0f3460; }
14
+ .card h2 { color: #00d4ff; font-size: 14px; margin-bottom: 15px; text-transform: uppercase; }
15
+ .stat { display: flex; justify-content: space-between; margin-bottom: 8px; }
16
+ .stat-label { color: #888; }
17
+ .stat-value { color: #fff; font-weight: bold; }
18
+ .provider { display: flex; align-items: center; margin-bottom: 10px; }
19
+ .status-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; }
20
+ .status-up { background: #00ff88; }
21
+ .status-error { background: #ff4444; }
22
+ .status-unknown { background: #888; }
23
+ .latency { color: #888; margin-left: auto; }
24
+ table { width: 100%; border-collapse: collapse; }
25
+ th, td { text-align: left; padding: 8px; border-bottom: 1px solid #0f3460; }
26
+ th { color: #00d4ff; font-size: 12px; }
27
+ td { font-size: 12px; }
28
+ .refresh { color: #888; font-size: 12px; margin-top: 10px; }
29
+ canvas { width: 100%; height: 120px; background: #0f3460; border-radius: 4px; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>Freegate — Панель управления</h1>
34
+ <div class="grid">
35
+ <div class="card"><h2>Провайдеры</h2><div id="providers">Загрузка...</div></div>
36
+ <div class="card"><h2>Статистика</h2><div id="stats">Загрузка...</div></div>
37
+ <div class="card"><h2>Кэш</h2><div id="cache">Загрузка...</div></div>
38
+ <div class="card"><h2>Токены</h2><div id="tokens">Загрузка...</div></div>
39
+ <div class="card"><h2>Очередь</h2><div id="pool">Загрузка...</div></div>
40
+ <div class="card" style="grid-column: span 2;"><h2>Лимиты (остаток за сегодня)</h2><div id="limits">Загрузка...</div></div>
41
+ <div class="card" style="grid-column: span 2;"><h2>График запросов (в минуту)</h2><canvas id="rpmChart"></canvas></div>
42
+ <div class="card" style="grid-column: span 2;">
43
+ <h2>Недавние запросы</h2>
44
+ <table id="recentTable"><tr><th>Время</th><th>Модель</th><th>Провайдер</th><th>Статус</th><th>Задержка</th></tr></table>
45
+ </div>
46
+ </div>
47
+ <div class="refresh">Автообновление: 5 сек</div>
48
+ <script>
49
+ // Carry the ?key= param from the dashboard URL to API calls
50
+ var _key = new URLSearchParams(location.search).get('key') || '';
51
+ function _api(path) {
52
+ return _key ? path + (path.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(_key) : path;
53
+ }
54
+ async function refresh() {
55
+ try {
56
+ const [statsRes, recentRes, rpmRes] = await Promise.all([
57
+ fetch(_api('/v1/stats')), fetch(_api('/v1/recent')), fetch(_api('/v1/rpm'))
58
+ ]);
59
+ const data = await statsRes.json();
60
+ const recent = (await recentRes.json()).data;
61
+ const rpmData = (await rpmRes.json()).data;
62
+ renderProviders(data.health);
63
+ renderStats(data);
64
+ renderCache(data.cache);
65
+ renderTokens(data.token_usage || {});
66
+ renderPool(data.pool || {});
67
+ renderLimits(data.limits || {});
68
+ renderRpm(rpmData);
69
+ renderRecent(recent);
70
+ } catch (e) { console.error('Ошибка обновления:', e); }
71
+ }
72
+
73
+ function renderProviders(health) {
74
+ let html = '';
75
+ for (const [key, h] of Object.entries(health)) {
76
+ const cls = h.status === 'up' ? 'status-up' : h.status === 'error' ? 'status-error' : 'status-unknown';
77
+ const rel = h.reliability !== null && h.reliability !== undefined ? ' | стаб. ' + h.reliability + '%' : '';
78
+ html += '<div class="provider"><div class="status-dot ' + cls + '"></div>';
79
+ html += '<span>' + key + '</span><span class="latency">' + h.latency_ms + 'ms · ' + (h.reason || '—') + rel + '</span></div>';
80
+ }
81
+ document.getElementById('providers').innerHTML = html || '<div>Нет провайдеров</div>';
82
+ }
83
+
84
+ function renderStats(data) {
85
+ document.getElementById('stats').innerHTML =
86
+ '<div class="stat"><span class="stat-label">Запросов</span><span class="stat-value">' + data.total_requests + '</span></div>' +
87
+ '<div class="stat"><span class="stat-label">Успешно</span><span class="stat-value">' + data.successful_requests + '</span></div>' +
88
+ '<div class="stat"><span class="stat-label">Ошибок</span><span class="stat-value">' + data.failed_requests + '</span></div>' +
89
+ '<div class="stat"><span class="stat-label">Время работы</span><span class="stat-value">' + Math.floor(data.uptime_seconds / 60) + ' мин</span></div>';
90
+ }
91
+
92
+ function renderCache(cache) {
93
+ if (!cache) return;
94
+ document.getElementById('cache').innerHTML =
95
+ '<div class="stat"><span class="stat-label">Хиты</span><span class="stat-value">' + cache.hits + '</span></div>' +
96
+ '<div class="stat"><span class="stat-label">Промахи</span><span class="stat-value">' + cache.misses + '</span></div>' +
97
+ '<div class="stat"><span class="stat-label">Размер</span><span class="stat-value">' + cache.size + '/' + cache.maxSize + '</span></div>' +
98
+ '<div class="stat"><span class="stat-label">Точность</span><span class="stat-value">' + cache.hitRate + '%</span></div>';
99
+ }
100
+
101
+ function renderTokens(tokens) {
102
+ let html = '';
103
+ for (const [key, t] of Object.entries(tokens)) {
104
+ html += '<div class="stat"><span class="stat-label">' + key + '</span><span class="stat-value">' + t.totalTokens + ' ток.</span></div>';
105
+ }
106
+ document.getElementById('tokens').innerHTML = html || '<div>Нет данных</div>';
107
+ }
108
+
109
+ function renderPool(pool) {
110
+ let html = '';
111
+ let totalQueued = 0;
112
+ for (const [key, s] of Object.entries(pool)) {
113
+ totalQueued += s.queued || 0;
114
+ if (s.queued > 0) {
115
+ html += '<div class="stat"><span class="stat-label">' + key + '</span><span class="stat-value">активных ' + s.active + ', ждут ' + s.queued + '</span></div>';
116
+ }
117
+ }
118
+ if (!html) html = '<div class="stat"><span class="stat-label">Все провайдеры</span><span class="stat-value">без очереди</span></div>';
119
+ document.getElementById('pool').innerHTML = html;
120
+ }
121
+
122
+ function renderLimits(limits) {
123
+ let html = '';
124
+ const keys = Object.keys(limits).sort((a, b) => (limits[a].percent || 0) - (limits[b].percent || 0));
125
+ for (const key of keys) {
126
+ const l = limits[key];
127
+ const color = l.percent > 90 ? '#ff4444' : l.percent > 60 ? '#ffaa00' : '#00ff88';
128
+ html += '<div class="stat"><span class="stat-label">' + key + '</span>' +
129
+ '<span class="stat-value" style="color:' + color + '">' + l.used + '/' + l.limit + ' (ост. ' + l.remaining + ', ' + l.percent + '%)</span></div>';
130
+ }
131
+ document.getElementById('limits').innerHTML = html || '<div>Нет данных</div>';
132
+ }
133
+
134
+ function renderRpm(rpmData) {
135
+ const canvas = document.getElementById('rpmChart');
136
+ const ctx = canvas.getContext('2d');
137
+ canvas.width = canvas.offsetWidth;
138
+ canvas.height = canvas.offsetHeight;
139
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
140
+ if (!rpmData || rpmData.length === 0) return;
141
+ const max = Math.max.apply(null, rpmData.map(r => r.count), 1);
142
+ const w = canvas.width, h = canvas.height;
143
+ ctx.strokeStyle = '#00d4ff';
144
+ ctx.lineWidth = 2;
145
+ ctx.beginPath();
146
+ rpmData.forEach((r, i) => {
147
+ const x = w - (rpmData.length - 1 - i) * (w / 60);
148
+ const y = h - (r.count / max) * (h - 20) - 10;
149
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
150
+ });
151
+ ctx.stroke();
152
+ }
153
+
154
+ function renderRecent(recent) {
155
+ const table = document.getElementById('recentTable');
156
+ table.innerHTML = '<tr><th>Время</th><th>Модель</th><th>Провайдер</th><th>Статус</th><th>Задержка</th></tr>';
157
+ recent.forEach(r => {
158
+ const row = table.insertRow();
159
+ row.insertCell().textContent = new Date(r.timestamp).toLocaleTimeString('ru-RU');
160
+ row.insertCell().textContent = r.model || '-';
161
+ row.insertCell().textContent = r.provider || '-';
162
+ row.insertCell().textContent = r.status || '-';
163
+ row.insertCell().textContent = (r.latency || 0) + 'ms';
164
+ });
165
+ }
166
+
167
+ refresh();
168
+ setInterval(refresh, 5000);
169
+ </script>
170
+ </body>
171
+ </html>`;
172
+
173
+ function handleDashboard(req, res) {
174
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
175
+ res.end(HTML);
176
+ }
177
+
178
+ module.exports = { handleDashboard };
package/lib/health.js ADDED
@@ -0,0 +1,179 @@
1
+ // lib/health.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const logger = require('./logger');
5
+
6
+ const STATE_PATH = path.join(__dirname, '..', 'state.json');
7
+
8
+ let health = {};
9
+ let circuitBreakers = {};
10
+ let stats = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, providerUsage: {}, errors: {}, startTime: Date.now(), tokenUsage: {} };
11
+
12
+ const MAX_RECENT = 50;
13
+ let recent = [];
14
+ let rpm = [];
15
+ let currentRpmMinute = 0;
16
+
17
+ function loadState() {
18
+ try {
19
+ const data = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
20
+ health = data.health || {};
21
+ circuitBreakers = data.circuitBreakers || {};
22
+ const saved = data.stats || {};
23
+ stats = {
24
+ totalRequests: saved.totalRequests || 0,
25
+ successfulRequests: saved.successfulRequests || 0,
26
+ failedRequests: saved.failedRequests || 0,
27
+ providerUsage: saved.providerUsage || {},
28
+ errors: saved.errors || {},
29
+ startTime: saved.startTime || Date.now(),
30
+ tokenUsage: saved.tokenUsage || {},
31
+ dailyUsage: saved.dailyUsage || {},
32
+ reliability: saved.reliability || {},
33
+ };
34
+ logger.info('State loaded', { healthKeys: Object.keys(health) });
35
+ } catch {
36
+ logger.info('No state file, starting fresh');
37
+ }
38
+ }
39
+
40
+ function saveState() {
41
+ try {
42
+ fs.writeFileSync(STATE_PATH, JSON.stringify({ health, circuitBreakers, stats }, null, 2));
43
+ } catch (err) {
44
+ logger.error('Failed to save state', { error: err.message });
45
+ }
46
+ }
47
+
48
+ function initHealth(key) {
49
+ if (!health[key]) {
50
+ health[key] = { score: 50, latency: 0, lastCheck: 0, status: 'unknown', quota: '--' };
51
+ }
52
+ }
53
+
54
+ function isCircuitOpen(key) {
55
+ const cb = circuitBreakers[key];
56
+ if (!cb || !cb.openUntil) return false;
57
+ if (Date.now() > cb.openUntil) {
58
+ cb.openUntil = null;
59
+ cb.failures = 0;
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ function recordSuccess(key) {
66
+ circuitBreakers[key] = { failures: 0, lastFailure: 0, openUntil: null };
67
+ initHealth(key);
68
+ health[key].status = 'up';
69
+ health[key].score = Math.min(100, health[key].score + 10);
70
+ }
71
+
72
+ function recordFailure(key, statusCode) {
73
+ if (!circuitBreakers[key]) circuitBreakers[key] = { failures: 0, lastFailure: 0, openUntil: null };
74
+ const cb = circuitBreakers[key];
75
+ cb.failures++;
76
+ cb.lastFailure = Date.now();
77
+ // Classify errors: hard errors open the breaker much longer than soft ones
78
+ const openMs = classifyErrorMs(statusCode);
79
+ if (cb.failures >= 3 || openMs > 60000) {
80
+ cb.openUntil = Date.now() + Math.max(openMs, 60000);
81
+ cb.failures = 0;
82
+ cb.reason = statusCode;
83
+ logger.warn('Circuit breaker opened', { key, statusCode, openMs });
84
+ }
85
+ }
86
+
87
+ // How long to hold the breaker open for a given HTTP status.
88
+ // 401/403 = bad key (long), 429 = rate limit (medium), 5xx = overload (short, retry soon)
89
+ function classifyErrorMs(statusCode) {
90
+ const s = statusCode || 0;
91
+ if (s === 401 || s === 403) return 300000; // 5 min — key is dead
92
+ if (s === 429) return 120000; // 2 min — rate limited, wait for reset
93
+ if (s >= 500) return 30000; // 30s — transient overload
94
+ if (s === 404) return 300000; // 5 min — model/endpoint gone
95
+ return 60000; // default
96
+ }
97
+
98
+ function recordRequest(providerKey, success, error = null) {
99
+ recordRpm();
100
+ stats.totalRequests++;
101
+ const today = new Date().toISOString().slice(0, 10);
102
+ if (success) {
103
+ stats.successfulRequests++;
104
+ stats.providerUsage[providerKey] = (stats.providerUsage[providerKey] || 0) + 1;
105
+ // Daily counter for limit tracking (resets each UTC day)
106
+ stats.dailyUsage = stats.dailyUsage || {};
107
+ stats.dailyUsage[providerKey] = stats.dailyUsage[providerKey] || {};
108
+ stats.dailyUsage[providerKey][today] = (stats.dailyUsage[providerKey][today] || 0) + 1;
109
+ // Reliability: count today's successes per provider
110
+ stats.reliability = stats.reliability || {};
111
+ stats.reliability[providerKey] = stats.reliability[providerKey] || { success: 0, fail: 0, day: '' };
112
+ const r = stats.reliability[providerKey];
113
+ if (r.day !== today) { r.day = today; r.success = 0; r.fail = 0; }
114
+ r.success++;
115
+ } else {
116
+ stats.failedRequests++;
117
+ if (error) stats.errors[providerKey] = (stats.errors[providerKey] || 0) + 1;
118
+ // Reliability: count today's failures
119
+ stats.reliability = stats.reliability || {};
120
+ stats.reliability[providerKey] = stats.reliability[providerKey] || { success: 0, fail: 0, day: '' };
121
+ const r = stats.reliability[providerKey];
122
+ if (r.day !== today) { r.day = today; r.success = 0; r.fail = 0; }
123
+ r.fail++;
124
+ }
125
+ }
126
+
127
+ // Reliability ratio (0..1) for today. Providers with 100% success get a boost at startup.
128
+ function getReliability() {
129
+ return stats.reliability || {};
130
+ }
131
+
132
+ function recordTokens(providerKey, usage) {
133
+ if (!usage) return;
134
+ const tu = stats.tokenUsage[providerKey] || { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
135
+ tu.promptTokens += usage.prompt_tokens || 0;
136
+ tu.completionTokens += usage.completion_tokens || 0;
137
+ tu.totalTokens += usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0);
138
+ stats.tokenUsage[providerKey] = tu;
139
+ }
140
+
141
+ function getHealth() { return health; }
142
+ function getStats() { return stats; }
143
+
144
+ function recordRecent(entry) {
145
+ recent.unshift({ timestamp: Date.now(), ...entry });
146
+ if (recent.length > MAX_RECENT) recent.pop();
147
+ }
148
+
149
+ function recordRpm() {
150
+ const now = Date.now();
151
+ const minute = Math.floor(now / 60000);
152
+ if (minute !== currentRpmMinute) {
153
+ currentRpmMinute = minute;
154
+ rpm.unshift({ timestamp: now, count: 1 });
155
+ if (rpm.length > 60) rpm.pop();
156
+ } else if (rpm.length > 0) {
157
+ rpm[0].count++;
158
+ } else {
159
+ rpm.unshift({ timestamp: now, count: 1 });
160
+ }
161
+ }
162
+
163
+ function getRecent() { return recent; }
164
+ function getRpm() { return rpm; }
165
+ function getDailyUsage() { return stats.dailyUsage || {}; }
166
+
167
+ // Auto-save every 30 seconds
168
+ const _saveTimer = setInterval(saveState, 30000);
169
+
170
+ function _stopTimers() { clearInterval(_saveTimer); }
171
+
172
+ module.exports = {
173
+ loadState, saveState, initHealth, isCircuitOpen,
174
+ recordSuccess, recordFailure, recordRequest, recordTokens,
175
+ getHealth, getStats,
176
+ recordRecent, recordRpm, getRecent, getRpm,
177
+ getDailyUsage, getReliability,
178
+ _stopTimers,
179
+ };
package/lib/logger.js ADDED
@@ -0,0 +1,48 @@
1
+ // lib/logger.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const LOG_PATH = path.join(__dirname, '..', 'proxy.log');
6
+ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
7
+ const MAX_ROTATIONS = 3;
8
+
9
+ function rotateLog() {
10
+ try {
11
+ const stat = fs.statSync(LOG_PATH);
12
+ if (stat.size < MAX_LOG_SIZE) return;
13
+
14
+ // Rotate: delete .3, shift .2 -> .3, .1 -> .2, proxy.log -> .1
15
+ try { if (fs.existsSync(`${LOG_PATH}.${MAX_ROTATIONS}`)) fs.unlinkSync(`${LOG_PATH}.${MAX_ROTATIONS}`); } catch {}
16
+ if (fs.existsSync(`${LOG_PATH}.2`)) fs.renameSync(`${LOG_PATH}.2`, `${LOG_PATH}.3`);
17
+ if (fs.existsSync(`${LOG_PATH}.1`)) fs.renameSync(`${LOG_PATH}.1`, `${LOG_PATH}.2`);
18
+ if (fs.existsSync(LOG_PATH)) fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
19
+ } catch (err) {
20
+ console.error(`[logger] rotation failed: ${err.message}`);
21
+ }
22
+ }
23
+
24
+ function formatTime() {
25
+ return new Date().toISOString();
26
+ }
27
+
28
+ function writeLog(level, message, data) {
29
+ const line = `[${formatTime()}] [${level}] ${message} ${data ? JSON.stringify(data) : ''}\n`;
30
+ process.stdout.write(line);
31
+ try {
32
+ rotateLog();
33
+ fs.appendFileSync(LOG_PATH, line);
34
+ } catch (err) {
35
+ console.error(`[logger] write failed: ${err.message}`);
36
+ }
37
+ }
38
+
39
+ module.exports = {
40
+ info: (msg, data) => writeLog('INFO', msg, data),
41
+ error: (msg, data) => writeLog('ERROR', msg, data),
42
+ warn: (msg, data) => writeLog('WARN', msg, data),
43
+ request: (data) => writeLog('REQ', `${data.model || '?'} ${data.provider || '?'} ${data.status || '?'}`, {
44
+ latency: data.latency,
45
+ stream: data.stream,
46
+ cached: data.cached,
47
+ }),
48
+ };
package/lib/pool.js ADDED
@@ -0,0 +1,41 @@
1
+ // lib/pool.js — per-provider concurrency limiting.
2
+ // Ensures at most N requests hit a provider simultaneously, so burst traffic
3
+ // can't burn daily limits in seconds or trip provider rate limits (429).
4
+ // Requests beyond the limit wait in a FIFO queue until a slot frees up.
5
+
6
+ const DEFAULT_CONCURRENCY = 6;
7
+
8
+ const slots = {}; // providerKey -> { active, queue: [] }
9
+
10
+ function acquire(key, max = DEFAULT_CONCURRENCY) {
11
+ if (!slots[key]) slots[key] = { active: 0, queue: [] };
12
+ const s = slots[key];
13
+ if (s.active < max) {
14
+ s.active++;
15
+ return Promise.resolve(() => release(key));
16
+ }
17
+ return new Promise((resolve) => {
18
+ s.queue.push(resolve);
19
+ }).then(() => {
20
+ s.active++;
21
+ return () => release(key);
22
+ });
23
+ }
24
+
25
+ function release(key) {
26
+ const s = slots[key];
27
+ if (!s) return;
28
+ s.active = Math.max(0, s.active - 1);
29
+ const next = s.queue.shift();
30
+ if (next) next();
31
+ }
32
+
33
+ function stats() {
34
+ const out = {};
35
+ for (const [key, s] of Object.entries(slots)) {
36
+ out[key] = { active: s.active, queued: s.queue.length };
37
+ }
38
+ return out;
39
+ }
40
+
41
+ module.exports = { acquire, release, stats };