protocol-proxy 2.7.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.7.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
@@ -6,6 +6,8 @@ let importData = null;
6
6
  let statsRange = 'daily';
7
7
  let statsProxyId = '';
8
8
  let providerPoolItems = [];
9
+ let keyHealth = {};
10
+ let statsAutoRefreshTimer = null;
9
11
 
10
12
  // ==================== 主题切换 ====================
11
13
 
@@ -81,6 +83,30 @@ function updateStats() {
81
83
  proxies.filter(p => p.running).length;
82
84
  }
83
85
 
86
+ async function loadKeyHealth() {
87
+ try {
88
+ const res = await fetch('/api/key-health');
89
+ keyHealth = await res.json();
90
+ refreshHealthUI();
91
+ } catch (err) {
92
+ console.error('加载 Key 健康状态失败:', err);
93
+ }
94
+ }
95
+
96
+ function refreshHealthUI() {
97
+ // 更新每个代理卡片上的健康点
98
+ document.querySelectorAll('.health-dot[data-provider]').forEach(dot => {
99
+ const h = keyHealth[dot.dataset.provider];
100
+ dot.className = 'health-dot';
101
+ if (!h || h.status === 'unknown') { dot.classList.add('health-unknown'); dot.title = '未检测'; }
102
+ else if (h.status === 'healthy') { dot.classList.add('health-ok'); dot.title = 'Key 正常'; }
103
+ else if (h.status === 'partial') { dot.classList.add('health-warn'); dot.title = '部分 Key 异常'; }
104
+ else { dot.classList.add('health-error'); dot.title = 'Key 全部异常'; }
105
+ });
106
+ // 更新汇总卡片
107
+ renderProviderHealthSummary();
108
+ }
109
+
84
110
  function parseProviderPool(value) {
85
111
  const text = (value || '').trim();
86
112
  if (!text) return [];
@@ -991,9 +1017,18 @@ function initStatsRangeBtns() {
991
1017
  document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
992
1018
  btn.classList.add('active');
993
1019
  statsRange = btn.dataset.range;
994
- // 清空日期选择器,显示全部数据
995
- document.getElementById('stats-start-date').value = '';
996
- document.getElementById('stats-end-date').value = '';
1020
+ // 清除自动刷新
1021
+ if (statsAutoRefreshTimer) { clearInterval(statsAutoRefreshTimer); statsAutoRefreshTimer = null; }
1022
+ if (statsRange === 'hourly') {
1023
+ // 实时模式:设为今天 + 30 秒自动刷新
1024
+ const today = new Date().toISOString().slice(0, 10);
1025
+ document.getElementById('stats-start-date').value = today;
1026
+ document.getElementById('stats-end-date').value = today;
1027
+ statsAutoRefreshTimer = setInterval(loadStats, 30000);
1028
+ } else {
1029
+ document.getElementById('stats-start-date').value = '';
1030
+ document.getElementById('stats-end-date').value = '';
1031
+ }
997
1032
  loadStats();
998
1033
  });
999
1034
  });
@@ -1054,7 +1089,16 @@ function initProviderPoolDropdown() {
1054
1089
  }
1055
1090
 
1056
1091
  async function init() {
1092
+ // 默认统计范围:当天(HTML 内联脚本已优先设置,此处兜底)
1093
+ const today = new Date().toISOString().slice(0, 10);
1094
+ const sd = document.getElementById('stats-start-date');
1095
+ const ed = document.getElementById('stats-end-date');
1096
+ if (!sd.value) sd.value = today;
1097
+ if (!ed.value) ed.value = today;
1057
1098
  await Promise.all([loadProxies(), loadProviders(), loadStats()]);
1099
+ // 延迟加载 health(等后端启动检测完成),之后每 5 分钟刷新
1100
+ setTimeout(() => loadKeyHealth(), 6000);
1101
+ setInterval(() => loadKeyHealth(), 5 * 60 * 1000);
1058
1102
  renderProxies();
1059
1103
  initProviderDropdown();
1060
1104
  initModelDropdown();
@@ -1087,6 +1131,7 @@ async function init() {
1087
1131
  if (document.getElementById('confirm-modal').classList.contains('active')) return;
1088
1132
  if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
1089
1133
  if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
1134
+ if (document.getElementById('request-log-modal').classList.contains('active')) { closeRequestLog(); return; }
1090
1135
  if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
1091
1136
  if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
1092
1137
  if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
@@ -1227,6 +1272,156 @@ async function loadLogs() {
1227
1272
  }
1228
1273
  }
1229
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
+
1230
1425
  // ==================== 版本历史 ====================
1231
1426
 
1232
1427
  async function openHistoryViewer() {
@@ -1327,9 +1522,64 @@ function filterProxies() {
1327
1522
  renderProxies();
1328
1523
  }
1329
1524
 
1525
+ function healthDot(providerId) {
1526
+ const h = keyHealth[providerId];
1527
+ const cls = !h || h.status === 'unknown' ? 'health-unknown'
1528
+ : h.status === 'healthy' ? 'health-ok'
1529
+ : h.status === 'partial' ? 'health-warn' : 'health-error';
1530
+ const title = !h || h.status === 'unknown' ? '未检测'
1531
+ : h.status === 'healthy' ? 'Key 正常'
1532
+ : h.status === 'partial' ? '部分 Key 异常' : 'Key 全部异常';
1533
+ return `<span class="health-dot ${cls}" data-provider="${escapeHtml(providerId)}" title="${title}"></span>`;
1534
+ }
1535
+
1536
+ function renderProviderHealthSummary() {
1537
+ const el = document.getElementById('provider-health-summary');
1538
+ if (!el) return;
1539
+ const allProviders = proxies.map(p => p.providerId).filter(Boolean);
1540
+ const unique = [...new Set(allProviders)];
1541
+ if (unique.length === 0) { el.style.display = 'none'; return; }
1542
+ let healthy = 0, partial = 0, unhealthy = 0, unknown = 0;
1543
+ for (const id of unique) {
1544
+ const h = keyHealth[id];
1545
+ if (!h || h.status === 'unknown') unknown++;
1546
+ else if (h.status === 'healthy') healthy++;
1547
+ else if (h.status === 'partial') partial++;
1548
+ else unhealthy++;
1549
+ }
1550
+ el.style.display = '';
1551
+ el.innerHTML = `
1552
+ <div class="health-stat"><span class="health-dot health-ok"></span><span>正常 ${healthy}</span></div>
1553
+ <div class="health-stat"><span class="health-dot health-warn"></span><span>部分异常 ${partial}</span></div>
1554
+ <div class="health-stat"><span class="health-dot health-error"></span><span>异常 ${unhealthy}</span></div>
1555
+ <div class="health-stat"><span class="health-dot health-unknown"></span><span>未检测 ${unknown}</span></div>
1556
+ <button class="btn btn-sm" onclick="recheckKeys()">重新检测</button>
1557
+ `;
1558
+ }
1559
+
1560
+ let rechecking = false;
1561
+ async function recheckKeys() {
1562
+ if (rechecking) return;
1563
+ rechecking = true;
1564
+ const btn = document.querySelector('.provider-health-summary .btn');
1565
+ if (btn) { btn.disabled = true; btn.textContent = '检测中...'; }
1566
+ showToast('正在检测...');
1567
+ try {
1568
+ await fetch('/api/key-health/check', { method: 'POST' });
1569
+ await loadKeyHealth();
1570
+ showToast('检测完成');
1571
+ } catch (err) {
1572
+ showToast('检测失败: ' + err.message, true);
1573
+ } finally {
1574
+ rechecking = false;
1575
+ if (btn) { btn.disabled = false; btn.textContent = '重新检测'; }
1576
+ }
1577
+ }
1578
+
1330
1579
  function renderProxies() {
1331
1580
  const container = document.getElementById('proxy-list');
1332
1581
  const list = getFilteredProxies();
1582
+ renderProviderHealthSummary();
1333
1583
  if (proxies.length === 0) {
1334
1584
  container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
1335
1585
  return;
@@ -1374,6 +1624,7 @@ function renderProxies() {
1374
1624
  </div>
1375
1625
  <div class="proxy-meta">
1376
1626
  <span>端口: <strong>${p.port}</strong></span>
1627
+ <span>供应商: ${healthDot(p.providerId)} ${escapeHtml(p.providerName || '-')}</span>
1377
1628
  <span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
1378
1629
  </div>
1379
1630
  <div class="proxy-address">
package/public/index.html CHANGED
@@ -7,6 +7,16 @@
7
7
  <link rel="stylesheet" href="style.css">
8
8
  </head>
9
9
  <body>
10
+ <script>
11
+ // 尽早设置统计日期默认值,避免 init() 时序问题
12
+ document.addEventListener('DOMContentLoaded', function() {
13
+ var today = new Date().toISOString().slice(0, 10);
14
+ var sd = document.getElementById('stats-start-date');
15
+ var ed = document.getElementById('stats-end-date');
16
+ if (sd && !sd.value) sd.value = today;
17
+ if (ed && !ed.value) ed.value = today;
18
+ });
19
+ </script>
10
20
  <div class="container">
11
21
  <button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换主题">
12
22
  <span id="theme-icon">&#9790;</span>
@@ -43,6 +53,7 @@
43
53
  </div>
44
54
  </div>
45
55
  <div class="stats-range-btns">
56
+ <button class="btn btn-sm stats-range-btn" data-range="hourly">实时</button>
46
57
  <button class="btn btn-sm stats-range-btn active" data-range="daily">每日</button>
47
58
  <button class="btn btn-sm stats-range-btn" data-range="monthly">每月</button>
48
59
  <button class="btn btn-sm stats-range-btn" data-range="yearly">每年</button>
@@ -95,8 +106,10 @@
95
106
  <button class="btn btn-sm" onclick="startAllProxies()">全部启动</button>
96
107
  <button class="btn btn-sm" onclick="stopAllProxies()">全部停止</button>
97
108
  <button class="btn btn-sm" onclick="openLogViewer()">日志</button>
109
+ <button class="btn btn-sm" onclick="openRequestLog()">请求日志</button>
98
110
  </div>
99
111
  </div>
112
+ <div class="provider-health-summary" id="provider-health-summary" style="display:none"></div>
100
113
  <div id="proxy-list" class="proxy-list">
101
114
  <div class="empty">加载中...</div>
102
115
  </div>
@@ -331,6 +344,52 @@
331
344
  </div>
332
345
  </div>
333
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
+
334
393
  <!-- 导入预览弹窗 -->
335
394
  <div class="modal" id="import-modal">
336
395
  <div class="modal-content" style="max-width:500px">
package/public/style.css CHANGED
@@ -1871,3 +1871,126 @@ form {
1871
1871
  font-size: 0.78rem;
1872
1872
  white-space: nowrap;
1873
1873
  }
1874
+
1875
+ /* Health dots */
1876
+ .health-dot {
1877
+ display: inline-block;
1878
+ width: 8px;
1879
+ height: 8px;
1880
+ border-radius: 50%;
1881
+ vertical-align: middle;
1882
+ margin-right: 4px;
1883
+ }
1884
+
1885
+ .health-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); }
1886
+ .health-warn { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); }
1887
+ .health-error { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
1888
+ .health-unknown { background: #64748b; }
1889
+
1890
+ /* Provider health summary */
1891
+ .provider-health-summary {
1892
+ display: flex;
1893
+ align-items: center;
1894
+ gap: 18px;
1895
+ padding: 12px 28px;
1896
+ border-bottom: 1px solid var(--border-light);
1897
+ background: var(--bg-surface-alt);
1898
+ font-size: 0.82rem;
1899
+ color: var(--text-muted);
1900
+ }
1901
+
1902
+ .health-stat {
1903
+ display: flex;
1904
+ align-items: center;
1905
+ gap: 5px;
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
@@ -249,6 +249,89 @@ async function init() {
249
249
  return proxyManager.startProxy(proxyConfig);
250
250
  }
251
251
 
252
+ // ==================== API Key 健康检查 ====================
253
+
254
+ const keyHealth = new Map(); // providerId -> { status, lastCheck, keys: [{index, ok, message}] }
255
+ let healthCheckRunning = false;
256
+
257
+ async function checkAllProviderKeys() {
258
+ if (healthCheckRunning) return;
259
+ healthCheckRunning = true;
260
+ try {
261
+ const providers = configStore.getProviders();
262
+ logger.log(`[Health] 开始检查 ${providers.length} 个供应商的 API Key...`);
263
+ for (const provider of providers) {
264
+ await checkProviderKeys(provider);
265
+ }
266
+ logger.log('[Health] API Key 健康检查完成');
267
+ } finally {
268
+ healthCheckRunning = false;
269
+ }
270
+ }
271
+
272
+ async function checkProviderKeys(provider) {
273
+ const keys = (provider.apiKeys || []).filter(k => k.enabled !== false);
274
+ if (keys.length === 0) {
275
+ keyHealth.set(provider.id, { status: 'unknown', lastCheck: Date.now(), keys: [] });
276
+ return;
277
+ }
278
+
279
+ const protocol = provider.protocol || 'openai';
280
+ const base = provider.url.replace(/\/$/, '');
281
+ const hasV1Suffix = base.endsWith('/v1');
282
+ const isAzure = protocol === 'openai' && !!provider.azureDeployment;
283
+
284
+ const results = await Promise.all(keys.map(async (k, i) => {
285
+ try {
286
+ let testUrl, fetchOpts;
287
+ if (protocol === 'openai') {
288
+ if (isAzure) {
289
+ const ver = provider.azureApiVersion || '2024-02-01';
290
+ testUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
291
+ fetchOpts = { headers: { 'api-key': k.key } };
292
+ } else {
293
+ testUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
294
+ fetchOpts = { headers: { 'Authorization': `Bearer ${k.key}` } };
295
+ }
296
+ } else if (protocol === 'anthropic') {
297
+ const testModel = (provider.models && provider.models[0]) || 'claude-3-haiku-20240307';
298
+ testUrl = hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`;
299
+ fetchOpts = {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json', 'x-api-key': k.key, 'anthropic-version': '2023-06-01' },
302
+ body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
303
+ };
304
+ } else if (protocol === 'gemini') {
305
+ testUrl = `${base}/v1beta/models?key=${k.key}`;
306
+ fetchOpts = {};
307
+ } else {
308
+ return { index: i, ok: false, message: '不支持的协议' };
309
+ }
310
+ const res = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
311
+ if (!res.ok) {
312
+ const hint = res.status === 401 || res.status === 403 ? 'Key 无效或无权限' : `HTTP ${res.status}`;
313
+ return { index: i, ok: false, message: hint };
314
+ }
315
+ return { index: i, ok: true };
316
+ } catch (err) {
317
+ return { index: i, ok: false, message: err.name === 'TimeoutError' ? '连接超时' : err.message };
318
+ }
319
+ }));
320
+
321
+ const allOk = results.every(r => r.ok);
322
+ const anyOk = results.some(r => r.ok);
323
+ keyHealth.set(provider.id, {
324
+ status: allOk ? 'healthy' : anyOk ? 'partial' : 'unhealthy',
325
+ lastCheck: Date.now(),
326
+ keys: results,
327
+ });
328
+ }
329
+
330
+ // 启动后延迟 5 秒执行首次检查
331
+ setTimeout(() => checkAllProviderKeys(), 5000);
332
+ // 每 24 小时检查一次
333
+ setInterval(() => checkAllProviderKeys(), 24 * 60 * 60 * 1000);
334
+
252
335
  // ==================== 供应商 API ====================
253
336
 
254
337
  app.get('/api/providers', (req, res) => {
@@ -720,6 +803,21 @@ async function init() {
720
803
  });
721
804
  });
722
805
 
806
+ // API Key 健康状态
807
+ app.get('/api/key-health', (req, res) => {
808
+ const result = {};
809
+ for (const [providerId, health] of keyHealth) {
810
+ result[providerId] = health;
811
+ }
812
+ res.json(result);
813
+ });
814
+
815
+ // 手动触发健康检查
816
+ app.post('/api/key-health/check', async (req, res) => {
817
+ await checkAllProviderKeys();
818
+ res.json({ success: true });
819
+ });
820
+
723
821
  // 设置
724
822
  app.get('/api/settings', (req, res) => {
725
823
  res.json(configStore.getSettings());
@@ -769,6 +867,13 @@ async function init() {
769
867
  }
770
868
  });
771
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
+
772
877
  // ==================== 配置导入/导出 ====================
773
878
 
774
879
  app.get('/api/config/export', (req, res) => {
@@ -938,11 +1043,19 @@ async function init() {
938
1043
  }
939
1044
  }));
940
1045
 
941
- app.listen(PORT, () => {
1046
+ const http = require('http');
1047
+ const server = app.listen(PORT, () => {
942
1048
  const adminUrl = `http://localhost:${PORT}`;
943
1049
  logger.log(`[Admin] Management server running on ${adminUrl}`);
944
1050
  logger.log(`[Admin] ${proxies.length} proxy config(s) loaded`);
945
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
+
946
1059
  openBrowser(adminUrl);
947
1060
  });
948
1061
  }
@@ -952,6 +1065,8 @@ process.on('SIGINT', async () => {
952
1065
  logger.log('[Shutdown] Shutting down...');
953
1066
  removePid();
954
1067
  try {
1068
+ const wsServer = require('./lib/ws-server');
1069
+ wsServer.close();
955
1070
  const proxyManager = require('./lib/proxy-manager');
956
1071
  const statsStore = require('./lib/stats-store');
957
1072
  statsStore.flush();
@@ -965,6 +1080,8 @@ process.on('SIGINT', async () => {
965
1080
  process.on('SIGTERM', async () => {
966
1081
  removePid();
967
1082
  try {
1083
+ const wsServer = require('./lib/ws-server');
1084
+ wsServer.close();
968
1085
  const proxyManager = require('./lib/proxy-manager');
969
1086
  const statsStore = require('./lib/stats-store');
970
1087
  statsStore.flush();