protocol-proxy 2.8.0 → 2.8.1
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/proxy-server.js +75 -1
- package/lib/request-log.js +27 -0
- package/lib/stats-store.js +26 -2
- package/lib/ws-server.js +46 -0
- package/package.json +4 -3
- package/public/app.js +151 -0
- package/public/index.html +47 -0
- package/public/style.css +90 -0
- package/server.js +20 -1
package/lib/proxy-server.js
CHANGED
|
@@ -8,6 +8,7 @@ const a2g = require('./converters/anthropic-to-gemini');
|
|
|
8
8
|
const g2a = require('./converters/gemini-to-anthropic');
|
|
9
9
|
const { recordUsage } = require('./stats-store');
|
|
10
10
|
const logger = require('./logger');
|
|
11
|
+
const requestLog = require('./request-log');
|
|
11
12
|
|
|
12
13
|
function createProxyApp(proxyConfigOrGetter) {
|
|
13
14
|
const getProxyConfig = typeof proxyConfigOrGetter === 'function'
|
|
@@ -343,6 +344,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
343
344
|
|
|
344
345
|
async function handleRequest(req, res) {
|
|
345
346
|
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
347
|
+
const requestStart = Date.now();
|
|
346
348
|
const proxyConfig = getProxyConfig();
|
|
347
349
|
const inboundProtocol = detectInboundProtocol(req, req.body);
|
|
348
350
|
const candidates = buildCandidates(proxyConfig);
|
|
@@ -353,6 +355,8 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
353
355
|
|
|
354
356
|
const isStream = req.body?.stream === true;
|
|
355
357
|
const proxyId = proxyConfig.id || 'default';
|
|
358
|
+
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
|
359
|
+
const proxyName = proxyConfig.name || '';
|
|
356
360
|
const inboundModel = req.body?.model;
|
|
357
361
|
const effectiveModel = proxyConfig.target?.defaultModel || inboundModel;
|
|
358
362
|
const baseRequestBody = effectiveModel ? { ...req.body, model: effectiveModel } : { ...req.body };
|
|
@@ -448,6 +452,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
448
452
|
|
|
449
453
|
const maxKeyRetries = (candidate.apiKeys || []).filter(k => typeof k === 'object' ? k.enabled !== false : true).length || 1;
|
|
450
454
|
let lastKeyError = null;
|
|
455
|
+
let keyLabel = '';
|
|
451
456
|
|
|
452
457
|
for (let keyAttempt = 0; keyAttempt < maxKeyRetries; keyAttempt++) {
|
|
453
458
|
const currentKey = selectKey(candidate.providerId, candidate.apiKeys || []);
|
|
@@ -493,7 +498,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
493
498
|
recordSuccess(proxyId, candidate.providerId, Date.now() - startedAt);
|
|
494
499
|
const keyEntry = (candidate.apiKeys || []).find(k => (typeof k === 'string' ? k : k.key) === currentKey);
|
|
495
500
|
const alias = keyEntry && typeof keyEntry === 'object' ? keyEntry.alias : '';
|
|
496
|
-
|
|
501
|
+
keyLabel = alias ? `${alias}(…${currentKey.slice(-4)})` : (currentKey ? `…${currentKey.slice(-4)}` : '-');
|
|
497
502
|
logger.log(`[${requestId}] ✓ ${candidate.providerName} | model=${candidateModel || '(default)'} key=${keyLabel} (${Date.now() - startedAt}ms)`);
|
|
498
503
|
|
|
499
504
|
if (isStream) {
|
|
@@ -543,6 +548,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
543
548
|
|
|
544
549
|
if (streamUsage) {
|
|
545
550
|
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, streamUsage, false);
|
|
551
|
+
requestLog.add({
|
|
552
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
553
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
554
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
555
|
+
status: 'success', upstreamStatusCode: null,
|
|
556
|
+
latencyMs: Date.now() - startedAt,
|
|
557
|
+
promptTokens: streamUsage.prompt_tokens || streamUsage.input_tokens || 0,
|
|
558
|
+
completionTokens: streamUsage.completion_tokens || streamUsage.output_tokens || 0,
|
|
559
|
+
totalTokens: (streamUsage.prompt_tokens || streamUsage.input_tokens || 0)
|
|
560
|
+
+ (streamUsage.completion_tokens || streamUsage.output_tokens || 0),
|
|
561
|
+
isEstimated: false, stream: true, keyAlias: keyLabel, errorMessage: null, clientIP,
|
|
562
|
+
});
|
|
546
563
|
} else if (responseText || toolCallCount > 0) {
|
|
547
564
|
const inputTokens = estimateInputTokens(req.body);
|
|
548
565
|
const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
|
|
@@ -550,6 +567,16 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
550
567
|
prompt_tokens: inputTokens,
|
|
551
568
|
completion_tokens: outputTokens,
|
|
552
569
|
}, true);
|
|
570
|
+
requestLog.add({
|
|
571
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
572
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
573
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
574
|
+
status: 'success', upstreamStatusCode: null,
|
|
575
|
+
latencyMs: Date.now() - startedAt,
|
|
576
|
+
promptTokens: inputTokens, completionTokens: outputTokens,
|
|
577
|
+
totalTokens: inputTokens + outputTokens,
|
|
578
|
+
isEstimated: true, stream: true, keyAlias: keyLabel, errorMessage: null, clientIP,
|
|
579
|
+
});
|
|
553
580
|
}
|
|
554
581
|
|
|
555
582
|
if (sseConverter) {
|
|
@@ -559,6 +586,15 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
559
586
|
} catch (err) {
|
|
560
587
|
recordFailure(proxyId, candidate.providerId);
|
|
561
588
|
logger.error(`[${requestId}] Stream error:`, err.message);
|
|
589
|
+
requestLog.add({
|
|
590
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
591
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
592
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
593
|
+
status: 'failure', upstreamStatusCode: null,
|
|
594
|
+
latencyMs: Date.now() - startedAt,
|
|
595
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
596
|
+
stream: true, keyAlias: keyLabel, errorMessage: err.message, clientIP,
|
|
597
|
+
});
|
|
562
598
|
if (!res.writableEnded) {
|
|
563
599
|
try {
|
|
564
600
|
res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
|
|
@@ -574,6 +610,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
574
610
|
extractReasoningFromResponse(responseBody);
|
|
575
611
|
extractAnthropicThinking(responseBody);
|
|
576
612
|
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, responseBody.usage);
|
|
613
|
+
requestLog.add({
|
|
614
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
615
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
616
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
617
|
+
status: 'success', upstreamStatusCode: fetchRes.status,
|
|
618
|
+
latencyMs: Date.now() - startedAt,
|
|
619
|
+
promptTokens: responseBody.usage?.prompt_tokens || responseBody.usage?.input_tokens || 0,
|
|
620
|
+
completionTokens: responseBody.usage?.completion_tokens || responseBody.usage?.output_tokens || 0,
|
|
621
|
+
totalTokens: (responseBody.usage?.prompt_tokens || responseBody.usage?.input_tokens || 0)
|
|
622
|
+
+ (responseBody.usage?.completion_tokens || responseBody.usage?.output_tokens || 0),
|
|
623
|
+
isEstimated: false, stream: false, keyAlias: keyLabel, errorMessage: null, clientIP,
|
|
624
|
+
});
|
|
577
625
|
const convertedBody = convertRes(responseBody);
|
|
578
626
|
return res.json(convertedBody);
|
|
579
627
|
} catch (err) {
|
|
@@ -584,6 +632,15 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
584
632
|
}
|
|
585
633
|
recordFailure(proxyId, candidate.providerId);
|
|
586
634
|
logger.error(`[${requestId}] ✗ ${candidate.providerName} | model=${candidateModel || '(default)'} - ${err.message}`);
|
|
635
|
+
requestLog.add({
|
|
636
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
637
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
638
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
639
|
+
status: 'failure', upstreamStatusCode: err?.status || null,
|
|
640
|
+
latencyMs: Date.now() - startedAt,
|
|
641
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
642
|
+
stream: isStream, keyAlias: keyLabel, errorMessage: err.message, clientIP,
|
|
643
|
+
});
|
|
587
644
|
if (err?.status && !isRetryableStatus(err.status)) {
|
|
588
645
|
return res.status(err.status).json({ error: err.message });
|
|
589
646
|
}
|
|
@@ -596,10 +653,27 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
596
653
|
if (lastKeyError) {
|
|
597
654
|
recordFailure(proxyId, candidate.providerId);
|
|
598
655
|
logger.error(`[${requestId}] ✗ ${candidate.providerName} | all keys rate-limited (429)`);
|
|
656
|
+
requestLog.add({
|
|
657
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
658
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
659
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
660
|
+
status: '429', upstreamStatusCode: 429,
|
|
661
|
+
latencyMs: Date.now() - startedAt,
|
|
662
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
663
|
+
stream: isStream, keyAlias: keyLabel, errorMessage: 'All keys rate-limited', clientIP,
|
|
664
|
+
});
|
|
599
665
|
}
|
|
600
666
|
} // end candidate loop
|
|
601
667
|
|
|
602
668
|
logger.error(`[${requestId}] 所有供应商均失败`);
|
|
669
|
+
requestLog.add({
|
|
670
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
671
|
+
inboundProtocol, targetProtocol: '', providerName: 'N/A', model: effectiveModel || '',
|
|
672
|
+
status: 'failure', upstreamStatusCode: null,
|
|
673
|
+
latencyMs: Date.now() - requestStart,
|
|
674
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
675
|
+
stream: isStream, keyAlias: '', errorMessage: 'All providers failed', clientIP,
|
|
676
|
+
});
|
|
603
677
|
return res.status(502).json({ error: 'All providers failed' });
|
|
604
678
|
}
|
|
605
679
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const MAX_ENTRIES = 1000;
|
|
2
|
+
|
|
3
|
+
const entries = [];
|
|
4
|
+
let listener = null;
|
|
5
|
+
|
|
6
|
+
function add(entry) {
|
|
7
|
+
entry.timestamp = entry.timestamp || new Date().toISOString();
|
|
8
|
+
entries.unshift(entry);
|
|
9
|
+
if (entries.length > MAX_ENTRIES) entries.pop();
|
|
10
|
+
if (listener) {
|
|
11
|
+
try { listener(entry); } catch { /* ignore */ }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getAll(limit) {
|
|
16
|
+
return entries.slice(0, limit || MAX_ENTRIES);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getCount() {
|
|
20
|
+
return entries.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onEntry(callback) {
|
|
24
|
+
listener = callback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { add, getAll, getCount, onEntry, MAX_ENTRIES };
|
package/lib/stats-store.js
CHANGED
|
@@ -29,6 +29,7 @@ function addToBuffer(period, date, statsKey, prompt, completion, estimated) {
|
|
|
29
29
|
function flush() {
|
|
30
30
|
const stats = readStats();
|
|
31
31
|
if (!dirty) return stats;
|
|
32
|
+
if (!stats.hourly) stats.hourly = {};
|
|
32
33
|
if (!stats.daily) stats.daily = {};
|
|
33
34
|
if (!stats.monthly) stats.monthly = {};
|
|
34
35
|
|
|
@@ -92,15 +93,31 @@ function yearKey(d) {
|
|
|
92
93
|
return String(d.getFullYear());
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
function hourKey(d) {
|
|
97
|
+
return dateKey(d) + '-' + String(d.getHours()).padStart(2, '0');
|
|
98
|
+
}
|
|
99
|
+
|
|
95
100
|
// ==================== 分层合并 ====================
|
|
96
101
|
|
|
97
102
|
function mergeIfNeeded() {
|
|
98
103
|
const stats = flush();
|
|
99
104
|
let changed = false;
|
|
100
105
|
|
|
106
|
+
const todayStr = dateKey(new Date());
|
|
101
107
|
const currentMonth = monthKey(new Date()); // "2026-05"
|
|
102
108
|
const currentYear = String(new Date().getFullYear());
|
|
103
109
|
|
|
110
|
+
// 小时级:清理昨天及更早的数据(daily 已有汇总,hourly 仅保留当天)
|
|
111
|
+
if (stats.hourly) {
|
|
112
|
+
const todayStart = todayStr + '-00';
|
|
113
|
+
for (const hk of Object.keys(stats.hourly)) {
|
|
114
|
+
if (hk < todayStart) {
|
|
115
|
+
delete stats.hourly[hk];
|
|
116
|
+
changed = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
104
121
|
// 日 → 月:非当月的日级数据合并为月级
|
|
105
122
|
if (stats.daily) {
|
|
106
123
|
const toMerge = {};
|
|
@@ -183,6 +200,7 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
|
|
|
183
200
|
if (!prompt && !completion) return;
|
|
184
201
|
|
|
185
202
|
const now = new Date();
|
|
203
|
+
const hk = hourKey(now);
|
|
186
204
|
const dk = dateKey(now);
|
|
187
205
|
const mk = monthKey(now);
|
|
188
206
|
|
|
@@ -195,6 +213,7 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
|
|
|
195
213
|
];
|
|
196
214
|
|
|
197
215
|
for (const key of keys) {
|
|
216
|
+
addToBuffer('hourly', hk, key, prompt, completion, estimated);
|
|
198
217
|
addToBuffer('daily', dk, key, prompt, completion, estimated);
|
|
199
218
|
}
|
|
200
219
|
}
|
|
@@ -208,9 +227,14 @@ function getStats(opts = {}) {
|
|
|
208
227
|
const stats = readStats();
|
|
209
228
|
const src = stats[range] || {};
|
|
210
229
|
|
|
230
|
+
// hourly keys are "YYYY-MM-DD-HH", need to pad day-level dates for comparison
|
|
231
|
+
const isHourly = range === 'hourly';
|
|
232
|
+
const filterStart = startDate ? (isHourly ? startDate + '-00' : startDate) : null;
|
|
233
|
+
const filterEnd = endDate ? (isHourly ? endDate + '-99' : endDate) : null;
|
|
234
|
+
|
|
211
235
|
const dates = Object.keys(src).filter(d => {
|
|
212
|
-
if (
|
|
213
|
-
if (
|
|
236
|
+
if (filterStart && d < filterStart) return false;
|
|
237
|
+
if (filterEnd && d > filterEnd) return false;
|
|
214
238
|
return true;
|
|
215
239
|
}).sort();
|
|
216
240
|
|
package/lib/ws-server.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const logger = require('./logger');
|
|
2
|
+
|
|
3
|
+
let wss = null;
|
|
4
|
+
let WebSocketServer = null;
|
|
5
|
+
|
|
6
|
+
function init(httpServer) {
|
|
7
|
+
try {
|
|
8
|
+
const ws = require('ws');
|
|
9
|
+
WebSocketServer = ws.WebSocketServer || ws.Server;
|
|
10
|
+
wss = new WebSocketServer({ server: httpServer });
|
|
11
|
+
|
|
12
|
+
wss.on('connection', (ws) => {
|
|
13
|
+
logger.log('[WS] Client connected');
|
|
14
|
+
try { ws.send(JSON.stringify({ type: 'connected', count: getClientCount() })); } catch { /* ignore */ }
|
|
15
|
+
ws.on('close', () => { logger.log('[WS] Client disconnected'); });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
logger.log('[WS] WebSocket server initialized');
|
|
19
|
+
} catch (err) {
|
|
20
|
+
logger.error('[WS] Failed to initialize WebSocket server:', err.message);
|
|
21
|
+
wss = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function broadcast(data) {
|
|
26
|
+
if (!wss) return;
|
|
27
|
+
const msg = JSON.stringify(data);
|
|
28
|
+
for (const client of wss.clients) {
|
|
29
|
+
if (client.readyState === 1 /* OPEN */) {
|
|
30
|
+
try { client.send(msg); } catch { /* ignore */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getClientCount() {
|
|
36
|
+
return wss ? wss.clients.size : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function close() {
|
|
40
|
+
if (wss) {
|
|
41
|
+
try { wss.close(); } catch { /* ignore */ }
|
|
42
|
+
wss = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { init, broadcast, getClientCount, close };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protocol-proxy",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "OpenAI / Anthropic 协议转换透明代理",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"cors": "^2.8.5",
|
|
30
|
-
"express": "^4.19.2"
|
|
30
|
+
"express": "^4.19.2",
|
|
31
|
+
"ws": "^8.20.1"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"pkg": "^5.8.1"
|
|
@@ -48,4 +49,4 @@
|
|
|
48
49
|
"engines": {
|
|
49
50
|
"node": ">=20.0.0"
|
|
50
51
|
}
|
|
51
|
-
}
|
|
52
|
+
}
|
package/public/app.js
CHANGED
|
@@ -1131,6 +1131,7 @@ async function init() {
|
|
|
1131
1131
|
if (document.getElementById('confirm-modal').classList.contains('active')) return;
|
|
1132
1132
|
if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
|
|
1133
1133
|
if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
|
|
1134
|
+
if (document.getElementById('request-log-modal').classList.contains('active')) { closeRequestLog(); return; }
|
|
1134
1135
|
if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
|
|
1135
1136
|
if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
|
|
1136
1137
|
if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
|
|
@@ -1271,6 +1272,156 @@ async function loadLogs() {
|
|
|
1271
1272
|
}
|
|
1272
1273
|
}
|
|
1273
1274
|
|
|
1275
|
+
// ==================== 实时请求日志 ====================
|
|
1276
|
+
|
|
1277
|
+
let requestLogWs = null;
|
|
1278
|
+
let requestLogEntries = [];
|
|
1279
|
+
let requestLogReconnectTimer = null;
|
|
1280
|
+
|
|
1281
|
+
function openRequestLog() {
|
|
1282
|
+
document.getElementById('request-log-modal').classList.add('active');
|
|
1283
|
+
populateRequestLogFilters();
|
|
1284
|
+
loadInitialRequestLogs();
|
|
1285
|
+
connectRequestLogWs();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function closeRequestLog() {
|
|
1289
|
+
document.getElementById('request-log-modal').classList.remove('active');
|
|
1290
|
+
disconnectRequestLogWs();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function connectRequestLogWs() {
|
|
1294
|
+
if (requestLogWs) return;
|
|
1295
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1296
|
+
const url = `${protocol}//${location.host}`;
|
|
1297
|
+
requestLogWs = new WebSocket(url);
|
|
1298
|
+
|
|
1299
|
+
requestLogWs.onopen = () => {
|
|
1300
|
+
const el = document.getElementById('request-log-ws-status');
|
|
1301
|
+
el.textContent = '已连接';
|
|
1302
|
+
el.style.color = '#34d399';
|
|
1303
|
+
if (requestLogReconnectTimer) { clearTimeout(requestLogReconnectTimer); requestLogReconnectTimer = null; }
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
requestLogWs.onmessage = (event) => {
|
|
1307
|
+
try {
|
|
1308
|
+
const entry = JSON.parse(event.data);
|
|
1309
|
+
if (entry.type === 'connected') return;
|
|
1310
|
+
requestLogEntries.unshift(entry);
|
|
1311
|
+
if (requestLogEntries.length > 2000) requestLogEntries.pop();
|
|
1312
|
+
appendRequestLogRow(entry);
|
|
1313
|
+
updateRequestLogCount();
|
|
1314
|
+
} catch { /* ignore */ }
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
requestLogWs.onclose = () => {
|
|
1318
|
+
requestLogWs = null;
|
|
1319
|
+
const el = document.getElementById('request-log-ws-status');
|
|
1320
|
+
el.textContent = '已断开';
|
|
1321
|
+
el.style.color = '#ef4444';
|
|
1322
|
+
if (document.getElementById('request-log-modal').classList.contains('active')) {
|
|
1323
|
+
requestLogReconnectTimer = setTimeout(connectRequestLogWs, 3000);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
requestLogWs.onerror = () => {};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function disconnectRequestLogWs() {
|
|
1331
|
+
if (requestLogWs) { requestLogWs.close(); requestLogWs = null; }
|
|
1332
|
+
if (requestLogReconnectTimer) { clearTimeout(requestLogReconnectTimer); requestLogReconnectTimer = null; }
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function loadInitialRequestLogs() {
|
|
1336
|
+
try {
|
|
1337
|
+
const res = await fetch('/api/request-logs?limit=200');
|
|
1338
|
+
const data = await res.json();
|
|
1339
|
+
requestLogEntries = data.entries || [];
|
|
1340
|
+
renderRequestLogTable();
|
|
1341
|
+
updateRequestLogCount();
|
|
1342
|
+
} catch { /* ignore */ }
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function populateRequestLogFilters() {
|
|
1346
|
+
const select = document.getElementById('request-log-proxy-filter');
|
|
1347
|
+
select.innerHTML = '<option value="">全部代理</option>' +
|
|
1348
|
+
(proxies || []).map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join('');
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function filterRequestLogs() {
|
|
1352
|
+
renderRequestLogTable();
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function getFilteredRequestLogs() {
|
|
1356
|
+
const proxyId = document.getElementById('request-log-proxy-filter').value;
|
|
1357
|
+
const status = document.getElementById('request-log-status-filter').value;
|
|
1358
|
+
const model = (document.getElementById('request-log-model-filter').value || '').trim().toLowerCase();
|
|
1359
|
+
|
|
1360
|
+
return requestLogEntries.filter(e => {
|
|
1361
|
+
if (proxyId && e.proxyId !== proxyId) return false;
|
|
1362
|
+
if (status && e.status !== status) return false;
|
|
1363
|
+
if (model && !(e.model || '').toLowerCase().includes(model)) return false;
|
|
1364
|
+
return true;
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function updateRequestLogCount() {
|
|
1369
|
+
const filtered = getFilteredRequestLogs();
|
|
1370
|
+
document.getElementById('request-log-count').textContent =
|
|
1371
|
+
`(显示 ${filtered.length}/${requestLogEntries.length})`;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function renderRequestLogTable() {
|
|
1375
|
+
const tbody = document.getElementById('request-log-tbody');
|
|
1376
|
+
const filtered = getFilteredRequestLogs();
|
|
1377
|
+
tbody.innerHTML = filtered.map(e => entryToRowHtml(e)).join('');
|
|
1378
|
+
updateRequestLogCount();
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function appendRequestLogRow(entry) {
|
|
1382
|
+
const proxyId = document.getElementById('request-log-proxy-filter').value;
|
|
1383
|
+
const status = document.getElementById('request-log-status-filter').value;
|
|
1384
|
+
const model = (document.getElementById('request-log-model-filter').value || '').trim().toLowerCase();
|
|
1385
|
+
if (proxyId && entry.proxyId !== proxyId) return;
|
|
1386
|
+
if (status && entry.status !== status) return;
|
|
1387
|
+
if (model && !(entry.model || '').toLowerCase().includes(model)) return;
|
|
1388
|
+
|
|
1389
|
+
const tbody = document.getElementById('request-log-tbody');
|
|
1390
|
+
const row = document.createElement('tr');
|
|
1391
|
+
row.className = 'request-log-row request-log-' + entry.status;
|
|
1392
|
+
row.innerHTML = entryToCellHtml(entry);
|
|
1393
|
+
tbody.insertBefore(row, tbody.firstChild);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function entryToRowHtml(e) {
|
|
1397
|
+
return `<tr class="request-log-row request-log-${e.status}">${entryToCellHtml(e)}</tr>`;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function entryToCellHtml(e) {
|
|
1401
|
+
const time = new Date(e.timestamp).toLocaleTimeString('zh-CN', { hour12: false });
|
|
1402
|
+
const statusLabel = e.status === 'success' ? '成功' : e.status === '429' ? '429' : '失败';
|
|
1403
|
+
const tokens = e.totalTokens > 0 ? `${e.promptTokens}+${e.completionTokens}` : '-';
|
|
1404
|
+
const latency = e.latencyMs != null ? `${e.latencyMs}ms` : '-';
|
|
1405
|
+
const statusSuffix = e.upstreamStatusCode ? ` (${e.upstreamStatusCode})` : '';
|
|
1406
|
+
return [
|
|
1407
|
+
`<td>${time}</td>`,
|
|
1408
|
+
`<td>${escapeHtml(e.proxyName || '-')}</td>`,
|
|
1409
|
+
`<td><span class="badge" style="font-size:11px">${escapeHtml(e.inboundProtocol || '-')}</span></td>`,
|
|
1410
|
+
`<td><code style="font-size:12px">${escapeHtml(e.model || '-')}</code></td>`,
|
|
1411
|
+
`<td><span class="request-log-status-badge request-log-status-${e.status}">${statusLabel}${statusSuffix}</span></td>`,
|
|
1412
|
+
`<td>${tokens}${e.isEstimated ? ' <span title="估算值" style="color:var(--text-dim)">~</span>' : ''}</td>`,
|
|
1413
|
+
`<td>${latency}</td>`,
|
|
1414
|
+
`<td>${escapeHtml(e.providerName || '-')}</td>`,
|
|
1415
|
+
`<td>${escapeHtml(e.keyAlias || '-')}</td>`,
|
|
1416
|
+
].join('');
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function clearRequestLogs() {
|
|
1420
|
+
requestLogEntries = [];
|
|
1421
|
+
document.getElementById('request-log-tbody').innerHTML = '';
|
|
1422
|
+
document.getElementById('request-log-count').textContent = '';
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1274
1425
|
// ==================== 版本历史 ====================
|
|
1275
1426
|
|
|
1276
1427
|
async function openHistoryViewer() {
|
package/public/index.html
CHANGED
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
<button class="btn btn-sm" onclick="startAllProxies()">全部启动</button>
|
|
107
107
|
<button class="btn btn-sm" onclick="stopAllProxies()">全部停止</button>
|
|
108
108
|
<button class="btn btn-sm" onclick="openLogViewer()">日志</button>
|
|
109
|
+
<button class="btn btn-sm" onclick="openRequestLog()">请求日志</button>
|
|
109
110
|
</div>
|
|
110
111
|
</div>
|
|
111
112
|
<div class="provider-health-summary" id="provider-health-summary" style="display:none"></div>
|
|
@@ -343,6 +344,52 @@
|
|
|
343
344
|
</div>
|
|
344
345
|
</div>
|
|
345
346
|
|
|
347
|
+
<!-- 实时请求日志弹窗 -->
|
|
348
|
+
<div class="modal" id="request-log-modal">
|
|
349
|
+
<div class="modal-content" style="max-width:1200px">
|
|
350
|
+
<div class="modal-header">
|
|
351
|
+
<h3>实时请求日志 <span id="request-log-count" style="color:#64748b;font-size:0.8rem;font-weight:400"></span></h3>
|
|
352
|
+
<button class="btn-close" onclick="closeRequestLog()">×</button>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="request-log-toolbar">
|
|
355
|
+
<div class="request-log-filters">
|
|
356
|
+
<select id="request-log-proxy-filter" onchange="filterRequestLogs()">
|
|
357
|
+
<option value="">全部代理</option>
|
|
358
|
+
</select>
|
|
359
|
+
<select id="request-log-status-filter" onchange="filterRequestLogs()">
|
|
360
|
+
<option value="">全部状态</option>
|
|
361
|
+
<option value="success">成功</option>
|
|
362
|
+
<option value="failure">失败</option>
|
|
363
|
+
<option value="429">429 限流</option>
|
|
364
|
+
</select>
|
|
365
|
+
<input type="text" id="request-log-model-filter" placeholder="模型过滤..." oninput="filterRequestLogs()">
|
|
366
|
+
</div>
|
|
367
|
+
<div class="request-log-controls">
|
|
368
|
+
<button class="btn btn-sm" id="request-log-ws-status" style="color:var(--text-dim);cursor:default">连接中...</button>
|
|
369
|
+
<button class="btn btn-sm" onclick="clearRequestLogs()">清空</button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
<div class="request-log-table-wrap" id="request-log-table-wrap">
|
|
373
|
+
<table class="request-log-table">
|
|
374
|
+
<thead>
|
|
375
|
+
<tr>
|
|
376
|
+
<th>时间</th>
|
|
377
|
+
<th>代理</th>
|
|
378
|
+
<th>协议</th>
|
|
379
|
+
<th>模型</th>
|
|
380
|
+
<th>状态</th>
|
|
381
|
+
<th>Tokens</th>
|
|
382
|
+
<th>延迟</th>
|
|
383
|
+
<th>供应商</th>
|
|
384
|
+
<th>Key</th>
|
|
385
|
+
</tr>
|
|
386
|
+
</thead>
|
|
387
|
+
<tbody id="request-log-tbody"></tbody>
|
|
388
|
+
</table>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
346
393
|
<!-- 导入预览弹窗 -->
|
|
347
394
|
<div class="modal" id="import-modal">
|
|
348
395
|
<div class="modal-content" style="max-width:500px">
|
package/public/style.css
CHANGED
|
@@ -1904,3 +1904,93 @@ form {
|
|
|
1904
1904
|
align-items: center;
|
|
1905
1905
|
gap: 5px;
|
|
1906
1906
|
}
|
|
1907
|
+
|
|
1908
|
+
/* ==================== 实时请求日志面板 ==================== */
|
|
1909
|
+
|
|
1910
|
+
.request-log-toolbar {
|
|
1911
|
+
display: flex;
|
|
1912
|
+
justify-content: space-between;
|
|
1913
|
+
align-items: center;
|
|
1914
|
+
padding: 8px 0;
|
|
1915
|
+
gap: 8px;
|
|
1916
|
+
flex-wrap: wrap;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
.request-log-filters {
|
|
1920
|
+
display: flex;
|
|
1921
|
+
gap: 8px;
|
|
1922
|
+
align-items: center;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
.request-log-filters select,
|
|
1926
|
+
.request-log-filters input {
|
|
1927
|
+
background: var(--bg-surface);
|
|
1928
|
+
color: var(--text-primary);
|
|
1929
|
+
border: 1px solid var(--border-input);
|
|
1930
|
+
border-radius: 6px;
|
|
1931
|
+
padding: 4px 8px;
|
|
1932
|
+
font-size: 12px;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
.request-log-controls {
|
|
1936
|
+
display: flex;
|
|
1937
|
+
gap: 8px;
|
|
1938
|
+
align-items: center;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
.request-log-table-wrap {
|
|
1942
|
+
max-height: 60vh;
|
|
1943
|
+
overflow-y: auto;
|
|
1944
|
+
border: 1px solid var(--border-light);
|
|
1945
|
+
border-radius: 8px;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
.request-log-table {
|
|
1949
|
+
width: 100%;
|
|
1950
|
+
border-collapse: collapse;
|
|
1951
|
+
font-size: 12px;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.request-log-table thead {
|
|
1955
|
+
position: sticky;
|
|
1956
|
+
top: 0;
|
|
1957
|
+
z-index: 1;
|
|
1958
|
+
background: var(--bg-elevated);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
.request-log-table th {
|
|
1962
|
+
padding: 8px 10px;
|
|
1963
|
+
text-align: left;
|
|
1964
|
+
font-weight: 600;
|
|
1965
|
+
color: var(--text-muted);
|
|
1966
|
+
border-bottom: 1px solid var(--border-main);
|
|
1967
|
+
font-size: 11px;
|
|
1968
|
+
text-transform: uppercase;
|
|
1969
|
+
letter-spacing: 0.05em;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
.request-log-table td {
|
|
1973
|
+
padding: 6px 10px;
|
|
1974
|
+
border-bottom: 1px solid var(--border-light);
|
|
1975
|
+
color: var(--text-secondary);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
.request-log-row:hover td {
|
|
1979
|
+
background: var(--bg-surface-alt);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
.request-log-success td:first-child { border-left: 3px solid #34d399; }
|
|
1983
|
+
.request-log-failure td:first-child { border-left: 3px solid #ef4444; }
|
|
1984
|
+
.request-log-429 td:first-child { border-left: 3px solid #fbbf24; }
|
|
1985
|
+
|
|
1986
|
+
.request-log-status-badge {
|
|
1987
|
+
display: inline-block;
|
|
1988
|
+
padding: 2px 8px;
|
|
1989
|
+
border-radius: 10px;
|
|
1990
|
+
font-size: 11px;
|
|
1991
|
+
font-weight: 500;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
.request-log-status-success { background: rgba(52,211,153,0.15); color: #34d399; }
|
|
1995
|
+
.request-log-status-failure { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
1996
|
+
.request-log-status-429 { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
package/server.js
CHANGED
|
@@ -867,6 +867,13 @@ async function init() {
|
|
|
867
867
|
}
|
|
868
868
|
});
|
|
869
869
|
|
|
870
|
+
// 实时请求日志
|
|
871
|
+
const requestLog = require('./lib/request-log');
|
|
872
|
+
app.get('/api/request-logs', (req, res) => {
|
|
873
|
+
const limit = Math.min(parseInt(req.query.limit) || 200, 2000);
|
|
874
|
+
res.json({ entries: requestLog.getAll(limit), total: requestLog.getCount() });
|
|
875
|
+
});
|
|
876
|
+
|
|
870
877
|
// ==================== 配置导入/导出 ====================
|
|
871
878
|
|
|
872
879
|
app.get('/api/config/export', (req, res) => {
|
|
@@ -1036,11 +1043,19 @@ async function init() {
|
|
|
1036
1043
|
}
|
|
1037
1044
|
}));
|
|
1038
1045
|
|
|
1039
|
-
|
|
1046
|
+
const http = require('http');
|
|
1047
|
+
const server = app.listen(PORT, () => {
|
|
1040
1048
|
const adminUrl = `http://localhost:${PORT}`;
|
|
1041
1049
|
logger.log(`[Admin] Management server running on ${adminUrl}`);
|
|
1042
1050
|
logger.log(`[Admin] ${proxies.length} proxy config(s) loaded`);
|
|
1043
1051
|
logger.log(`[Admin] 日志文件: ${logger.LOG_FILE}`);
|
|
1052
|
+
|
|
1053
|
+
// 初始化 WebSocket 实时日志
|
|
1054
|
+
const wsServer = require('./lib/ws-server');
|
|
1055
|
+
wsServer.init(server);
|
|
1056
|
+
requestLog.onEntry((entry) => wsServer.broadcast(entry));
|
|
1057
|
+
logger.log(`[Admin] WebSocket 已附加 (ws://localhost:${PORT})`);
|
|
1058
|
+
|
|
1044
1059
|
openBrowser(adminUrl);
|
|
1045
1060
|
});
|
|
1046
1061
|
}
|
|
@@ -1050,6 +1065,8 @@ process.on('SIGINT', async () => {
|
|
|
1050
1065
|
logger.log('[Shutdown] Shutting down...');
|
|
1051
1066
|
removePid();
|
|
1052
1067
|
try {
|
|
1068
|
+
const wsServer = require('./lib/ws-server');
|
|
1069
|
+
wsServer.close();
|
|
1053
1070
|
const proxyManager = require('./lib/proxy-manager');
|
|
1054
1071
|
const statsStore = require('./lib/stats-store');
|
|
1055
1072
|
statsStore.flush();
|
|
@@ -1063,6 +1080,8 @@ process.on('SIGINT', async () => {
|
|
|
1063
1080
|
process.on('SIGTERM', async () => {
|
|
1064
1081
|
removePid();
|
|
1065
1082
|
try {
|
|
1083
|
+
const wsServer = require('./lib/ws-server');
|
|
1084
|
+
wsServer.close();
|
|
1066
1085
|
const proxyManager = require('./lib/proxy-manager');
|
|
1067
1086
|
const statsStore = require('./lib/stats-store');
|
|
1068
1087
|
statsStore.flush();
|