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.
@@ -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
- const keyLabel = alias ? `${alias}(…${currentKey.slice(-4)})` : (currentKey ? `…${currentKey.slice(-4)}` : '-');
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 };
@@ -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 (startDate && d < startDate) return false;
213
- if (endDate && d > endDate) return false;
236
+ if (filterStart && d < filterStart) return false;
237
+ if (filterEnd && d > filterEnd) return false;
214
238
  return true;
215
239
  }).sort();
216
240
 
@@ -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.0",
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()">&times;</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
- app.listen(PORT, () => {
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();